"""
Smart Card Builder v2 - DSL + Embeddings Focused
A streamlined card builder that uses:
1. Structure DSL: symbol[childΓN] for component hierarchy
2. Content DSL: symbol 'text' style for styled content
3. Qdrant ColBERT embeddings for semantic search
4. ModuleWrapper for component loading
NOTE: All DSL symbols are dynamically generated from ModuleWrapper.
Query wrapper.symbol_mapping for current symbol-to-component mappings.
Common examples (may vary): Β§ (Section), Ξ΄ (DecoratedText), Ι (ButtonList), ᡬ (Button)
This replaces the legacy NL parsing with a cleaner DSL-first approach.
Usage:
builder = SmartCardBuilderV2()
# Get current symbols from wrapper
symbols = builder._get_wrapper().symbol_mapping
card = builder.build(
description="Β§[Ξ΄Γ3, Ι[ᡬΓ2]] Dashboard", # Use symbols from mapping
title="My Card"
)
"""
import json
import logging
import os
import random
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from functools import wraps
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
from dotenv import load_dotenv
from adapters.module_wrapper.strict import warn_strict
from config.settings import settings as _settings
from middleware.filters import register_all_filters
from middleware.filters.styling_filters import SEMANTIC_COLORS
from middleware.template_core.jinja_environment import JinjaEnvironmentManager
# NOTE: Rendering utilities are imported lazily to avoid circular imports
# with gchat.card_builder.__init__.py. Use direct module import when needed:
# from gchat.card_builder.rendering import get_json_key, etc.
load_dotenv()
logger = logging.getLogger(__name__)
# Feature flag for feedback buttons
ENABLE_FEEDBACK_BUTTONS = os.getenv("ENABLE_CARD_FEEDBACK", "true").lower() == "true"
# =============================================================================
# IMPORTS FROM card_builder PACKAGE (migrated modules)
# =============================================================================
# These were previously defined inline. Now imported from card_builder package.
# Fire-and-forget decorator
# Component params registry
# =============================================================================
# COMPONENT PATHS AND DETECTION PATTERNS - Imported from card_builder.constants
# =============================================================================
from gchat.card_builder.constants import (
COMPONENT_PARAMS,
COMPONENT_PATHS,
FEEDBACK_DETECTION_PATTERNS,
)
# NOTE: COMPONENT_PARAMS is imported from card_builder.constants above
# =============================================================================
# DYNAMIC FEEDBACK BUILDER - Imported from card_builder.feedback
# =============================================================================
# The DynamicFeedbackBuilder class has been migrated to gchat.card_builder.feedback.dynamic
# Import it from there for full functionality.
# =============================================================================
# FEEDBACK CONSTANTS - Imported from card_builder.feedback package
# =============================================================================
# All feedback prompts, icons, components, and configs are now in card_builder.feedback
from gchat.card_builder.feedback import (
BUTTON_TYPES,
# Configs
CLICK_CONFIGS,
CLICKABLE_COMPONENTS,
# Prompts
CONTENT_FEEDBACK_PROMPTS,
DUAL_COMPONENTS,
FEEDBACK_COLORS,
FEEDBACK_ICONS,
FEEDBACK_MATERIAL_ICONS,
FEEDBACK_TEXT_STYLES,
FORM_FEEDBACK_PROMPTS,
LAYOUT_CONFIGS,
LAYOUT_WRAPPERS,
NEGATIVE_ICONS,
NEGATIVE_IMAGE_URLS,
NEGATIVE_LABELS,
NEGATIVE_MATERIAL_ICONS,
POSITIVE_ICONS,
POSITIVE_IMAGE_URLS,
POSITIVE_LABELS,
# Icons
POSITIVE_MATERIAL_ICONS,
SECTION_STYLES,
# Component registries
TEXT_COMPONENTS,
TEXT_CONFIGS,
DynamicFeedbackBuilder,
get_dynamic_feedback_builder,
)
# Style metadata extraction
from gchat.card_builder.jinja_styling import extract_style_metadata
# Metadata accessors (query wrapper or fall back to static dicts)
from gchat.card_builder.metadata import (
get_children_field,
get_container_child_type,
get_context_resource,
is_empty_component,
is_form_component,
)
# =============================================================================
# PREPARED PATTERNS - Imported from card_builder.prepared_pattern
# =============================================================================
# InputMappingReport, PreparedPattern, prepare_pattern, prepare_pattern_from_dsl
# are now in gchat.card_builder.prepared_pattern
from gchat.card_builder.prepared_pattern import (
InputMappingReport,
PreparedPattern,
prepare_pattern,
prepare_pattern_from_dsl,
)
from gchat.card_builder.utils import fire_and_forget
class SmartCardBuilderV2:
"""
Streamlined card builder using DSL + Qdrant embeddings.
Flow:
1. Parse Structure DSL from description (symbols from ModuleWrapper)
2. Parse Content DSL for styled content (symbol 'text' style)
3. Build card from parsed structure with styled content
4. Add feedback section for learning loop
NOTE: All DSL symbols are dynamically generated from ModuleWrapper.
Query wrapper.symbol_mapping for current symbol-to-component mappings.
"""
def __init__(self):
"""Initialize the builder."""
self._wrapper = None
self._qdrant_client = None
self._embedder = None
self._jinja_env = None
self._jinja_applied = False # Track if Jinja processing was applied
# Performance optimization: LRU cache for Qdrant pattern queries
# Cache entries expire after 5 minutes to balance freshness vs performance
self._pattern_cache: Dict[str, Dict[str, Any]] = {}
self._pattern_cache_timestamps: Dict[str, float] = {}
self._pattern_cache_ttl = 300 # 5 minutes
self._pattern_cache_max_size = 100
# =========================================================================
# INFRASTRUCTURE
# =========================================================================
def _get_wrapper(self):
"""Get or create the ModuleWrapper singleton."""
if self._wrapper is None:
try:
from gchat.card_framework_wrapper import get_card_framework_wrapper
self._wrapper = get_card_framework_wrapper()
except Exception as e:
logger.warning(f"Could not get ModuleWrapper: {e}")
return self._wrapper
def _build_material_icon(
self, icon_name: str, fill: bool = None, weight: int = None
) -> Dict[str, Any]:
"""Build a materialIcon dict. Delegates to card_builder.rendering.build_material_icon()."""
from gchat.card_builder.rendering import build_material_icon
return build_material_icon(icon_name, fill, weight)
def _build_start_icon(self, icon_name: str) -> Dict[str, Any]:
"""Build a startIcon dict. Delegates to card_builder.rendering.build_start_icon()."""
from gchat.card_builder.rendering import build_start_icon
return build_start_icon(icon_name)
def _get_qdrant_client(self):
"""Get or create the Qdrant client."""
if self._qdrant_client is None:
try:
from config.qdrant_client import get_qdrant_client
self._qdrant_client = get_qdrant_client()
except Exception as e:
logger.warning(f"Could not get Qdrant client: {e}")
return self._qdrant_client
def _get_cache_key(
self, description: str, card_params: Optional[Dict[str, Any]] = None
) -> str:
"""Generate a cache key from description and params."""
import hashlib
params_str = str(sorted(card_params.items())) if card_params else ""
key_str = f"{description}:{params_str}"
return hashlib.md5(key_str.encode()).hexdigest()
def _get_cached_pattern(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get a cached pattern if valid (not expired)."""
if cache_key in self._pattern_cache:
timestamp = self._pattern_cache_timestamps.get(cache_key, 0)
if time.time() - timestamp < self._pattern_cache_ttl:
logger.debug(f"β‘ Cache hit for pattern query: {cache_key[:8]}...")
return self._pattern_cache[cache_key]
else:
# Expired, remove from cache
del self._pattern_cache[cache_key]
del self._pattern_cache_timestamps[cache_key]
return None
def _cache_pattern(self, cache_key: str, pattern: Optional[Dict[str, Any]]) -> None:
"""Cache a pattern result with TTL."""
# Evict oldest entries if cache is full
if len(self._pattern_cache) >= self._pattern_cache_max_size:
oldest_key = min(
self._pattern_cache_timestamps, key=self._pattern_cache_timestamps.get
)
del self._pattern_cache[oldest_key]
del self._pattern_cache_timestamps[oldest_key]
self._pattern_cache[cache_key] = pattern
self._pattern_cache_timestamps[cache_key] = time.time()
def _get_dsl_parser(self):
"""Get the DSL parser from card_framework_wrapper."""
try:
from gchat.card_framework_wrapper import get_dsl_parser
return get_dsl_parser()
except Exception as e:
logger.warning(f"Could not get DSL parser: {e}")
return None
def _get_jinja_env(self):
"""Get or create the Jinja2 environment with styling filters."""
if self._jinja_env is None:
try:
manager = JinjaEnvironmentManager()
self._jinja_env = manager.setup_jinja2_environment()
if self._jinja_env:
# Register all styling filters
register_all_filters(self._jinja_env)
logger.debug(
"π¨ Jinja2 environment initialized with styling filters"
)
except Exception as e:
logger.warning(f"Could not initialize Jinja2 environment: {e}")
return self._jinja_env
def _process_text_with_jinja(self, text: str) -> str:
"""
Process text through Jinja2 environment for styling.
Handles both explicit Jinja syntax ({{ 'text' | filter }}) and
raw HTML that needs to pass through unchanged.
Args:
text: Text content that may contain Jinja expressions or HTML
Returns:
Processed text with Jinja expressions rendered
"""
if not text:
return text
jinja_env = self._get_jinja_env()
if not jinja_env:
return text
try:
# Create template from the text string
template = jinja_env.from_string(text)
# Render with empty context (filters work without context)
result = template.render()
# Only mark as applied if the text actually contained Jinja expressions
# that were processed (i.e., the output differs from input)
if result != text:
self._jinja_applied = True
logger.debug(
f"π¨ Jinja template processed: '{text[:50]}...' -> '{result[:50]}...'"
)
return result
except Exception as e:
logger.debug(f"Jinja2 processing skipped: {e}")
return text # Return original on error
# =========================================================================
# QDRANT PATTERN LOOKUP
# =========================================================================
def _extract_paths_from_pattern(self, pattern: Dict[str, Any]) -> List[str]:
"""Extract component paths from pattern. Delegates to card_builder.search."""
from gchat.card_builder.search import extract_paths_from_pattern
return extract_paths_from_pattern(pattern)
def _query_wrapper_patterns(
self,
description: str,
card_params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Query patterns using wrapper's SearchMixin methods (preferred).
Uses DSL-aware search when DSL symbols are detected in the description,
otherwise falls back to hybrid V7 search with positive feedback filters.
Searches are parallelized when DSL is detected for improved latency.
Args:
description: Card description (may include DSL symbols)
card_params: Additional params to include in search context
Returns:
Dict with component_paths, instance_params from best match, or None
"""
try:
from gchat.card_framework_wrapper import get_card_framework_wrapper
wrapper = get_card_framework_wrapper()
# Check for DSL in description (fast, synchronous - determines search strategy)
extracted = wrapper.extract_dsl_from_text(description)
has_dsl = extracted.get("has_dsl")
if has_dsl:
logger.info(f"π€ DSL detected: {extracted['dsl']}")
# Run searches in parallel when DSL is detected (both searches are independent)
with ThreadPoolExecutor(max_workers=2) as executor:
# Always submit hybrid search for style_metadata extraction
hybrid_future = executor.submit(
wrapper.search_hybrid,
description=description,
component_paths=None,
limit=5,
token_ratio=1.0,
content_feedback="positive",
form_feedback="positive",
include_classes=False, # Only want patterns
)
# Conditionally submit DSL search in parallel
dsl_future = None
if has_dsl:
dsl_future = executor.submit(
wrapper.search_by_dsl,
text=description,
limit=5,
score_threshold=0.3,
vector_name="inputs", # Search for patterns
type_filter="instance_pattern",
)
# Gather results (blocks until complete)
class_results, content_patterns, form_patterns = hybrid_future.result()
dsl_results = dsl_future.result() if dsl_future else None
# Find patterns with actual style_metadata content (for auto-styling)
styled_pattern = None
for pattern in content_patterns:
instance_params = pattern.get("instance_params", {})
style_meta = instance_params.get("style_metadata", {})
# Check for actual style content, not just empty dict
if style_meta.get("semantic_styles") or style_meta.get("jinja_filters"):
styled_pattern = pattern
logger.info(f"π¨ Found pattern with style_metadata: {style_meta}")
break
# Process DSL results if available
if dsl_results:
best = dsl_results[0]
component_paths = self._extract_paths_from_pattern(best)
instance_params = best.get("instance_params", {})
# Check if DSL result has actual style content (not just empty dict)
dsl_style = instance_params.get("style_metadata", {})
dsl_has_styles = dsl_style.get("semantic_styles") or dsl_style.get(
"jinja_filters"
)
# Merge style_metadata from styled_pattern if DSL result lacks actual styles
if styled_pattern and not dsl_has_styles:
styled_instance_params = styled_pattern.get("instance_params", {})
styled_style = styled_instance_params.get("style_metadata", {})
if styled_style.get("semantic_styles") or styled_style.get(
"jinja_filters"
):
instance_params = {
**instance_params,
"style_metadata": styled_style,
}
logger.info(
f"π¨ Merged style_metadata from similar pattern: {styled_style}"
)
return {
"component_paths": component_paths,
"instance_params": instance_params,
"structure_description": best.get("structure_description", ""),
"score": best.get("score", 0),
"source": "wrapper_dsl",
}
# No DSL results - use hybrid search results
if content_patterns:
# Prefer patterns with style_metadata
best = styled_pattern if styled_pattern else content_patterns[0]
component_paths = self._extract_paths_from_pattern(best)
return {
"component_paths": component_paths,
"instance_params": best.get("instance_params", {}),
"structure_description": best.get("structure_description", ""),
"score": best.get("score", 0),
"source": "wrapper_hybrid",
}
return None
except Exception as e:
logger.debug(f"Wrapper pattern search failed: {e}")
return None
def _query_qdrant_patterns(
self,
description: str,
card_params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Query Qdrant for matching instance patterns with caching.
First tries wrapper's SearchMixin methods (search_by_dsl, search_hybrid),
then falls back to feedback_loop.query_with_discovery() using Qdrant's Discovery API.
Results are cached for 5 minutes to improve performance.
Args:
description: Card description (may include DSL symbols)
card_params: Additional params to include in search context
Returns:
Dict with component_paths, instance_params from best match, or None
"""
# Check cache first
cache_key = self._get_cache_key(description, card_params)
cached_result = self._get_cached_pattern(cache_key)
if cached_result is not None:
return (
cached_result if cached_result else None
) # Handle cached None as empty dict
# Try wrapper-based search first (DSL-aware, cleaner)
wrapper_result = self._query_wrapper_patterns(description, card_params)
if wrapper_result:
logger.info(
f"β
Wrapper pattern match (source: {wrapper_result.get('source', 'unknown')})"
)
self._cache_pattern(cache_key, wrapper_result)
return wrapper_result
# Fall back to feedback_loop (has negative demotion support)
try:
from gchat.feedback_loop import get_feedback_loop
feedback_loop = get_feedback_loop()
# Query using description - symbols in description will match
# symbol-enriched embeddings in Qdrant
# component_query searches 'components' vector, description searches 'inputs' vector
class_results, content_patterns, form_patterns = (
feedback_loop.query_with_discovery(
component_query=description,
description=description,
limit=5,
)
)
# Use best matching content pattern (has positive content_feedback)
if content_patterns:
best_pattern = content_patterns[0]
logger.info(
f"π― Found Qdrant pattern: {best_pattern.get('structure_description', '')[:50]}..."
)
# Extract component_paths from multiple possible fields
component_paths = self._extract_paths_from_pattern(best_pattern)
result = {
"component_paths": component_paths,
"instance_params": best_pattern.get("instance_params", {}),
"structure_description": best_pattern.get(
"structure_description", ""
),
"score": best_pattern.get("score", 0),
}
self._cache_pattern(cache_key, result)
return result
# If no content patterns, try class results
if class_results:
# Extract component paths from class results
component_paths = [r.get("name", "") for r in class_results[:5]]
logger.info(f"π Found class components: {component_paths}")
result = {
"component_paths": component_paths,
"instance_params": {},
"structure_description": f"From classes: {', '.join(component_paths)}",
"score": class_results[0].get("score", 0) if class_results else 0,
}
self._cache_pattern(cache_key, result)
return result
# Cache the "no result" case too (as empty dict)
self._cache_pattern(cache_key, {})
return None
except Exception as e:
logger.warning(f"Qdrant pattern query failed: {e}")
return None
def _generate_pattern_from_wrapper(
self,
description: str,
card_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Generate a valid instance pattern using ModuleWrapper relationships.
Similar to how feedback section and smoke_test_generator work -
use ModuleWrapper's component hierarchy to create valid structures.
Args:
description: Original card description
card_params: Parameters to determine component structure
Returns:
Dict with component_paths and instance_params
"""
# Determine what components to use based on params
component_paths = ["Section"] # Always start with Section
if card_params:
# Add text component if we have text content
if card_params.get("text") or card_params.get("description"):
component_paths.append("DecoratedText")
# Add button list if we have buttons
if card_params.get("buttons"):
component_paths.append("ButtonList")
# Add individual buttons
for _ in card_params["buttons"]:
component_paths.append("Button")
# Add image if we have image URL
if card_params.get("image_url"):
component_paths.append("Image")
# Default: Section with TextParagraph if nothing specified
if len(component_paths) == 1:
component_paths.append("TextParagraph")
logger.info(f"π§ Generated pattern: {component_paths}")
return {
"component_paths": component_paths,
"instance_params": card_params or {},
"structure_description": f"Generated from: {description[:100]}",
}
def _build_from_pattern(
self,
pattern: Dict[str, Any],
card_params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Build card from Qdrant pattern.
Similar to how _create_feedback_section() builds widgets -
we construct the Google Chat JSON structure directly based on
the component_paths from the pattern.
Args:
pattern: Dict with component_paths and instance_params
card_params: Additional params (title, subtitle, text, buttons, etc.)
Returns:
Card dict in Google Chat format
"""
component_paths = pattern.get("component_paths", [])
instance_params = pattern.get("instance_params", {})
# Get style metadata from matched pattern
style_metadata = instance_params.get("style_metadata", {})
# Merge card_params into instance_params (card_params takes precedence)
params = {**instance_params, **(card_params or {})}
# Auto-apply styles from matched pattern if not already styled
if style_metadata and not self._has_explicit_styles(params):
params = self._apply_pattern_styles(params, style_metadata)
# Build widgets based on component paths
widgets = []
text_consumed = False
buttons_consumed = False
image_consumed = False
wrapper = self._get_wrapper()
for comp_name in component_paths:
if comp_name in ("DecoratedText", "decoratedText") and not text_consumed:
text = params.get("text") or params.get("description", "")
if text:
widget = self._build_component(
"DecoratedText",
{"text": text, "wrap_text": True},
wrapper=wrapper,
wrap_with_key=True,
)
if widget:
widgets.append(widget)
text_consumed = True
elif comp_name in ("TextParagraph", "textParagraph") and not text_consumed:
text = params.get("text") or params.get("description", "")
if text:
widget = self._build_component(
"TextParagraph",
{"text": text},
wrapper=wrapper,
wrap_with_key=True,
)
if widget:
widgets.append(widget)
text_consumed = True
elif comp_name in ("ButtonList", "buttonList") and not buttons_consumed:
buttons = params.get("buttons", [])
if buttons:
# Build button instances via wrapper
btn_instances = []
for btn in buttons:
if isinstance(btn, dict):
btn_instance = self._build_component(
"Button",
{
"text": btn.get("text", "Button"),
"url": btn.get("url"),
},
wrapper=wrapper,
return_instance=True,
)
if btn_instance:
btn_instances.append(btn_instance)
# Build ButtonList with button instances
if btn_instances:
widget = self._build_component(
"ButtonList",
{},
wrapper=wrapper,
wrap_with_key=True,
child_instances=btn_instances,
)
if widget:
widgets.append(widget)
buttons_consumed = True
elif comp_name in ("Image", "image") and not image_consumed:
image_url = params.get("image_url")
if image_url:
widget = self._build_component(
"Image",
{"imageUrl": image_url},
wrapper=wrapper,
wrap_with_key=True,
)
if widget:
widgets.append(widget)
image_consumed = True
elif comp_name in ("Divider", "divider"):
widget = self._build_component(
"Divider",
{},
wrapper=wrapper,
wrap_with_key=True,
)
if widget:
widgets.append(widget)
elif comp_name == "Section":
# Section is a container, not a widget - skip
pass
elif comp_name in ("Card", "CardHeader"):
# Card and CardHeader are top-level containers, not widgets - skip
pass
elif comp_name == "Button":
# Individual buttons are handled by ButtonList - skip
pass
elif comp_name in ("OnClick", "OpenLink", "onClick", "openLink"):
# OnClick/OpenLink are nested inside buttons, not standalone widgets - skip
pass
# Dynamic fallback: Use generic widget builder
else:
# Create minimal context for generic builder
context = {
"buttons": [],
"chips": [],
"content_texts": [],
"_button_index": 0,
"_chip_index": 0,
"_text_index": 0,
}
widget = self._build_widget_generic(comp_name, [], context)
if widget:
widgets.append(widget)
# If we have text but no text widget was added, add one now
if not text_consumed and (params.get("text") or params.get("description")):
text = params.get("text") or params.get("description", "")
widget = self._build_component(
"DecoratedText",
{"text": text, "wrap_text": True},
wrapper=wrapper,
wrap_with_key=True,
)
if widget:
widgets.insert(0, widget)
# If we have buttons but no button widget was added, add one now
if not buttons_consumed and params.get("buttons"):
buttons = params["buttons"]
btn_instances = []
for btn in buttons:
if isinstance(btn, dict):
btn_instance = self._build_component(
"Button",
{"text": btn.get("text", "Button"), "url": btn.get("url")},
wrapper=wrapper,
return_instance=True,
)
if btn_instance:
btn_instances.append(btn_instance)
if btn_instances:
widget = self._build_component(
"ButtonList",
{},
wrapper=wrapper,
wrap_with_key=True,
child_instances=btn_instances,
)
if widget:
widgets.append(widget)
# Build card structure
if not widgets:
return None
card = {"sections": [{"widgets": widgets}]}
# Add header
title = params.get("title")
subtitle = params.get("subtitle")
if title:
card["header"] = {"title": title}
if subtitle:
card["header"]["subtitle"] = subtitle
return card
def _has_explicit_styles(self, params: Dict[str, Any]) -> bool:
"""Check if params have explicit Jinja styles. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import has_explicit_styles
return has_explicit_styles(params)
def _apply_pattern_styles(
self, params: Dict[str, Any], style_metadata: Dict[str, List[str]]
) -> Dict[str, Any]:
"""Apply pattern styles to text fields. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import apply_pattern_styles
return apply_pattern_styles(params, style_metadata)
def _get_style_for_text(
self, text: str, style_metadata: Dict[str, List[str]]
) -> Optional[str]:
"""Get style for text based on content. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import get_style_for_text
return get_style_for_text(text, style_metadata)
def _apply_style_to_text(
self, text: str, style_metadata: Dict[str, List[str]]
) -> str:
"""Apply style to text string. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import apply_style_to_text
return apply_style_to_text(text, style_metadata)
def _apply_styles_recursively(
self, obj: Any, style_metadata: Dict[str, List[str]], depth: int = 0
) -> Any:
"""Apply styles recursively. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import apply_styles_recursively
return apply_styles_recursively(obj, style_metadata, depth)
def _format_text_for_chat(self, text: str) -> str:
"""Format text for Google Chat. Delegates to card_builder.jinja_styling."""
from gchat.card_builder.jinja_styling import format_text_for_chat
return format_text_for_chat(text, self._get_jinja_env())
# =========================================================================
# DSL EXTRACTION
# =========================================================================
def _extract_structure_dsl(self, description: str) -> Optional[str]:
"""
Extract Structure DSL notation from description.
Extracts symbol patterns like "Symbol[ChildΓN]" from start of description.
Symbols are dynamically mapped via ModuleWrapper.
"""
if not description:
return None
try:
from gchat.card_framework_wrapper import extract_dsl_from_description
return extract_dsl_from_description(description)
except Exception as e:
logger.debug(f"DSL extraction failed: {e}")
return None
def _parse_content_dsl(self, description: str) -> Optional[Dict[str, Any]]:
"""
Parse Content DSL from description.
Content DSL format (symbols from ModuleWrapper):
<symbol> 'text content' style_modifier
<symbol> Label https://url.com
Returns dict with styled texts and buttons.
"""
if not description:
return None
try:
parser = self._get_dsl_parser()
if not parser:
return None
# Extract content after Structure DSL
structure_dsl = self._extract_structure_dsl(description)
if structure_dsl:
content_text = description[len(structure_dsl) :].strip()
else:
content_text = description.strip()
if not content_text:
return None
# Find Content DSL lines (starting with symbols)
symbols = set(parser.reverse_symbols.keys())
lines = content_text.split("\n")
content_dsl_lines = []
for line in lines:
stripped = line.strip()
if not stripped:
continue
if stripped[0] in symbols:
content_dsl_lines.append(line)
elif line.startswith((" ", "\t")) and content_dsl_lines:
content_dsl_lines.append(line)
if not content_dsl_lines:
return None
# Parse Content DSL
content_dsl_text = "\n".join(content_dsl_lines)
result = parser.parse_content_dsl(content_dsl_text)
if not result.is_valid or not result.blocks:
return None
logger.info(f"π¨ Content DSL: Parsed {len(result.blocks)} blocks")
# Build structured result
parsed = {"blocks": [], "buttons": [], "texts": []}
for block in result.blocks:
block_info = {
"symbol": block.primary.symbol,
"component": block.primary.component_name,
"content": block.full_content,
"styles": [m.name for m in block.primary.modifiers],
"url": block.primary.url,
}
parsed["blocks"].append(block_info)
if block.primary.component_name == "Button":
parsed["buttons"].append(
{
"text": block.full_content,
"url": block.primary.url,
}
)
else:
styled = self._apply_styles(
block.full_content, [m.name for m in block.primary.modifiers]
)
parsed["texts"].append(
{
"content": block.full_content,
"styled": styled,
"component": block.primary.component_name,
}
)
return parsed
except Exception as e:
logger.debug(f"Content DSL parsing failed: {e}")
return None
def _apply_styles(self, text: str, styles: List[str]) -> str:
"""Apply Content DSL styles to text, generating Google Chat HTML.
Delegates to gchat.card_builder.jinja_styling.apply_styles().
"""
from gchat.card_builder.jinja_styling import apply_styles
return apply_styles(text, styles, SEMANTIC_COLORS)
# =========================================================================
# CARD BUILDING FROM DSL
# =========================================================================
def _build_from_dsl(
self,
structure_dsl: str,
description: str,
title: Optional[str] = None,
subtitle: Optional[str] = None,
buttons: Optional[List[Dict]] = None,
chips: Optional[List[Dict]] = None,
image_url: Optional[str] = None,
text: Optional[str] = None,
items: Optional[List[Dict]] = None,
cards: Optional[List[Dict]] = None,
) -> Optional[Dict[str, Any]]:
"""Build a card from parsed DSL structure.
Handles both regular card DSL and carousel DSL (symbols from ModuleWrapper).
"""
wrapper = self._get_wrapper()
if not wrapper:
return None
try:
validator = wrapper.get_structure_validator()
parsed = validator.parse_structure(structure_dsl)
if not parsed:
return None
logger.info(f"π Parsed DSL into {len(parsed)} component(s)")
# Parse Content DSL for styled content
content_dsl = self._parse_content_dsl(description)
# Override buttons with Content DSL buttons
if content_dsl and content_dsl.get("buttons") and not buttons:
buttons = content_dsl["buttons"]
# Build sections from structure
sections = []
text_index = 0
content_texts = content_dsl.get("texts", []) if content_dsl else []
# If no Content DSL texts but explicit text provided, add it to content_texts
# This ensures card_params.text flows through the component system properly
if not content_texts and text:
# Process text through Jinja for styling support
styled_text = self._format_text_for_chat(text)
content_texts = [{"text": text, "styled": styled_text}]
logger.info(f"π Using explicit text param: {text[:50]}...")
# If items array provided, convert to content_texts for DecoratedText widgets
# Each item can have: text, icon, top_label, bottom_label, wrapText
if items:
for item in items:
item_text = item.get("text", "")
styled_text = self._format_text_for_chat(item_text)
content_entry = {"text": item_text, "styled": styled_text}
# Pass through additional fields for DecoratedText
if item.get("icon"):
content_entry["icon"] = item["icon"]
if item.get("top_label"):
content_entry["top_label"] = self._format_text_for_chat(
item["top_label"]
)
if item.get("bottom_label"):
content_entry["bottom_label"] = self._format_text_for_chat(
item["bottom_label"]
)
# Always default wrapText to True for DecoratedText items
content_entry["wrapText"] = item.get("wrapText", True)
content_texts.append(content_entry)
logger.info(f"π Added {len(items)} items to content_texts")
# Create shared context for unified resource consumption
# This ensures buttons, texts, chips, cards, etc. are consumed sequentially across all components
context = {
"buttons": buttons or [],
"chips": chips or [],
"carousel_cards": cards
or [], # For carousel DSL (symbols from ModuleWrapper)
"grid_items": items or [], # For grid DSL (GridItem consumption)
"image_url": image_url,
"content_texts": content_texts,
"_button_index": 0, # Track button consumption
"_chip_index": 0, # Track chip consumption
"_carousel_card_index": 0, # Track carousel card consumption
"_grid_item_index": 0, # Track grid item consumption
"_text_index": 0, # Track text consumption
"_mapping_report": InputMappingReport(), # Track input consumption
}
for component in parsed:
comp_name = component.get("name", "")
multiplier = component.get("multiplier", 1)
children = component.get("children", [])
# Section is special - it contains widgets, not a widget itself
if comp_name == "Section":
widgets = self._build_widgets(
children, buttons, image_url, content_texts, context
)
if widgets:
sections.append({"widgets": widgets})
else:
# GENERIC: All other top-level components use _build_widget_generic
for _ in range(multiplier):
widget = self._build_widget_generic(
comp_name, children, context
)
if widget:
sections.append({"widgets": [widget]})
if not sections:
return None
# Build card
card = {"sections": sections}
if title:
card["header"] = {"title": title}
if subtitle:
card["header"]["subtitle"] = subtitle
return card
except Exception as e:
logger.warning(f"DSL card building failed: {e}")
return None
# =========================================================================
# GENERIC WIDGET BUILDING (Mapping-Driven, No If/Else)
# =========================================================================
def _get_json_key(self, component_name: str) -> str:
"""Get camelCase JSON key from component name.
Delegates to gchat.card_builder.rendering.get_json_key().
E.g., "DecoratedText" -> "decoratedText", "ButtonList" -> "buttonList"
"""
from gchat.card_builder.rendering import get_json_key
return get_json_key(component_name)
def _consume_from_context(
self,
component_name: str,
context: Dict[str, Any],
) -> Dict[str, Any]:
"""Consume resource from context. Delegates to card_builder.context."""
from gchat.card_builder.context import consume_from_context
return consume_from_context(component_name, context, self._get_wrapper())
def _build_widget_generic(
self,
component_name: str,
grandchildren: List[Dict],
context: Dict[str, Any],
explicit_params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Generic widget builder - handles ANY component type.
This replaces all the if/elif component_name == "X" chains with:
1. Dynamic JSON key derivation
2. Context-based resource consumption
3. Generic container/child handling
4. Wrapper-based rendering
Args:
component_name: Component type (e.g., "DecoratedText", "ButtonList")
grandchildren: Nested children from DSL
context: Shared context for resource consumption
explicit_params: Any explicit params to include
Returns:
Widget dict in Google Chat format, or None if build fails
"""
# Skip non-widget components (containers, top-level structures)
# Components that are NOT standalone widgets - skip them
NON_WIDGET_COMPONENTS = {
"Card",
"CardHeader",
"Section", # Top-level containers
"OnClick",
"OpenLink",
"onClick",
"openLink", # Nested inside buttons
"Button", # Handled by ButtonList
"Icon",
"icon", # Nested inside decoratedText.startIcon or buttons
}
if component_name in NON_WIDGET_COMPONENTS:
return None
json_key = self._get_json_key(component_name)
params = explicit_params.copy() if explicit_params else {}
wrapper = self._get_wrapper()
# 1. Empty components (just structure, no content)
if is_empty_component(component_name, wrapper):
return {json_key: {}}
# 2. Consume from context if this component type uses resources
consumed = self._consume_from_context(component_name, context)
# Consumed values are defaults, explicit_params override
for k, v in consumed.items():
params.setdefault(k, v)
# 2.5 Ensure text-bearing components have a text field
# When content_texts is empty (no explicit text/items provided), DecoratedText
# and TextParagraph would render without text. Use a non-breaking space as
# minimal content so the widget is valid for Google Chat API.
if (
component_name in ("DecoratedText", "TextParagraph")
and "text" not in params
):
params["text"] = "\u00a0" # non-breaking space β valid minimal content
# 3. Container components - build children recursively
children_field = get_children_field(component_name, wrapper)
if children_field:
logger.debug(
f"π Container component: {component_name} "
f"(children_field={children_field}, "
f"children_count={len(grandchildren)})"
)
result = self._build_container_generic(
component_name, grandchildren, context, params
)
logger.debug(
f"π Container result for {component_name}: "
f"{result.keys() if result else 'None'}"
)
return result
# 4. Components with nested children (like DecoratedText with Button)
# Build children first - they come back as pre-built JSON dicts
child_params = {}
if grandchildren:
child_params = self._map_children_to_params(
component_name, grandchildren, context
)
# 5. Form components need a name field
if is_form_component(component_name, wrapper):
input_index = context.get("_input_index", 0)
params.setdefault("name", f"{component_name.lower()}_{input_index}")
params.setdefault("label", component_name.replace("Input", " Input"))
context["_input_index"] = input_index + 1
# 5.5 Pre-process DecoratedText special fields (icon -> startIcon, labels, wrapText)
# Must happen BEFORE wrapper build so wrapper gets clean params
decorated_text_extras = {}
if component_name == "DecoratedText":
if params.get("icon"):
from gchat.material_icons import resolve_icon_name
icon_name = resolve_icon_name(params.pop("icon"))
decorated_text_extras["startIcon"] = {
"materialIcon": {"name": icon_name}
}
if params.get("top_label"):
decorated_text_extras["topLabel"] = params.pop("top_label")
if params.get("bottom_label"):
decorated_text_extras["bottomLabel"] = params.pop("bottom_label")
# Always ensure wrapText is set (default True for multi-line content)
decorated_text_extras["wrapText"] = params.pop("wrapText", True)
# 6. Build via wrapper for proper type handling (only if no pre-built children)
# When there are pre-built children (JSON dicts), wrapper can't use them directly
if not child_params:
wrapper = self._get_wrapper()
if wrapper:
built = self._build_component(component_name, params, wrapper=wrapper)
if built:
# Merge in DecoratedText extras (icon, labels) after wrapper build
if decorated_text_extras:
built.update(decorated_text_extras)
return {json_key: built}
# 7. Build widget with merged children
# For components with nested children, merge child_params into the base params
widget_content = {}
# Add base params (e.g., text for DecoratedText)
# Process text through Jinja for consistent styling support
if "text" in params:
widget_content["text"] = self._format_text_for_chat(params["text"])
if component_name == "DecoratedText":
widget_content.setdefault("wrapText", True)
# Merge in pre-processed extras (icon -> startIcon, labels)
widget_content.update(decorated_text_extras)
# Add any other params
for k, v in params.items():
if k not in widget_content:
widget_content[k] = v
# Merge pre-built children
widget_content.update(child_params)
# Handle Image special case
if component_name == "Image":
img_url = params.get("imageUrl") or context.get("image_url")
if not img_url:
logger.debug("Image component has no URL, skipping")
return None
return {json_key: {"imageUrl": img_url}}
# Return None instead of "Item" placeholder if no content
# This ensures only explicitly provided content is rendered
if not widget_content:
warn_strict(
f"_build_widget_generic('{component_name}'): widget_content is empty. "
f"Widget will be omitted from the card."
)
return None
return {json_key: widget_content}
def _build_container_generic(
self,
component_name: str,
children: List[Dict],
context: Dict[str, Any],
params: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Build container components (ButtonList, Grid, etc.) generically.
Uses wrapper.get_children_field() and wrapper.get_container_child_type() (SSoT).
"""
json_key = self._get_json_key(component_name)
wrapper = self._get_wrapper()
children_field = get_children_field(component_name, wrapper) or "widgets"
expected_child_type = get_container_child_type(component_name, wrapper)
# Count expected children from DSL
if expected_child_type and children:
expected_count = sum(
c.get("multiplier", 1)
for c in children
if c.get("name") == expected_child_type
)
else:
expected_count = 1
# Special case: Columns has nested structure
if component_name == "Columns":
return self._build_columns_generic(children, context)
# Build child items
built_children = []
for _ in range(expected_count):
if expected_child_type == "Button":
# Consume button from context
btn_params = self._consume_from_context("Button", context)
btn_obj = {"text": btn_params.get("text", "Button")}
if btn_params.get("url"):
btn_obj["onClick"] = {"openLink": {"url": btn_params["url"]}}
# Add Material Icon if provided (e.g., "add_circle", "share")
if btn_params.get("icon"):
btn_obj["icon"] = {"materialIcon": {"name": btn_params["icon"]}}
built_children.append(btn_obj)
elif expected_child_type == "GridItem":
grid_params = self._consume_from_context("GridItem", context)
grid_item = {
"title": grid_params.get("title", f"Item {len(built_children) + 1}")
}
if grid_params.get("subtitle"):
grid_item["subtitle"] = grid_params["subtitle"]
if grid_params.get("image_url"):
grid_item["image"] = {"imageUri": grid_params["image_url"]}
elif grid_params.get("image"):
grid_item["image"] = grid_params["image"]
built_children.append(grid_item)
elif expected_child_type == "Chip":
# Consume chip from context (similar to Button)
chip_params = self._consume_from_context("Chip", context)
chip_obj = {
"label": chip_params.get("label")
or chip_params.get("text", f"Chip {len(built_children) + 1}")
}
if chip_params.get("url"):
chip_obj["onClick"] = {"openLink": {"url": chip_params["url"]}}
# Add icon if provided
if chip_params.get("icon"):
chip_obj["icon"] = {"materialIcon": {"name": chip_params["icon"]}}
built_children.append(chip_obj)
elif expected_child_type == "CarouselCard":
# Consume carousel card from context and build via wrapper
card_params = self._consume_from_context("CarouselCard", context)
logger.debug(
f"π CarouselCard #{len(built_children)}: "
f"params={list(card_params.keys())}"
)
# Use wrapper's component system for CarouselCard
wrapper = self._get_wrapper()
if wrapper:
# CRITICAL: CarouselCard only accepts 'widgets' array, not direct
# title/text fields. Transform title/text into TextParagraph widgets.
if (
"title" in card_params or "text" in card_params
) and "widgets" not in card_params:
widgets = []
idx = len(built_children)
# Add title as bold text paragraph using wrapper
title = card_params.get("title", f"Card {idx + 1}")
if title:
formatted_title = self._format_text_for_chat(title)
title_widget = self._build_component(
"TextParagraph",
{"text": f"<b>{formatted_title}</b>"},
wrapper=wrapper,
wrap_with_key=True,
)
if title_widget:
widgets.append(title_widget)
# Add main text content using wrapper
text = card_params.get("text")
if text:
text_widget = self._build_component(
"TextParagraph",
{"text": text},
wrapper=wrapper,
wrap_with_key=True,
)
if text_widget:
widgets.append(text_widget)
# Add buttons using wrapper ButtonList
buttons = card_params.get("buttons", [])
if buttons:
btn_instances = []
for b in buttons:
btn = self._build_component(
"Button",
{
"text": b.get("text", "Button"),
"url": b.get("url"),
},
wrapper=wrapper,
return_instance=True,
)
if btn:
btn_instances.append(btn)
if btn_instances:
btn_list_widget = self._build_component(
"ButtonList",
{},
wrapper=wrapper,
wrap_with_key=True,
child_instances=btn_instances,
)
if btn_list_widget:
widgets.append(btn_list_widget)
# Add image if provided
image_url = card_params.get("image_url") or card_params.get(
"image"
)
if image_url:
image_widget = self._build_component(
"Image",
{"image_url": image_url},
wrapper=wrapper,
wrap_with_key=True,
)
if image_widget:
widgets.append(image_widget)
built_children.append({"widgets": widgets})
logger.debug(
f"π Built CarouselCard with {len(widgets)} widget(s) "
f"(transformed from title/text)"
)
elif "widgets" in card_params:
# Already has widgets array - use as-is
built_children.append({"widgets": card_params["widgets"]})
logger.debug(f"π CarouselCard using provided widgets array")
else:
# Empty card - create placeholder
logger.debug(f"π CarouselCard with no content, skipping")
continue
else:
# Skip generic child fallback - don't create "Item N" placeholders
# This ensures only explicitly provided content is rendered
logger.debug(
f"π Skipping fallback for unknown child type in {component_name}"
)
continue
if not built_children:
warn_strict(
f"_build_container_generic('{component_name}'): no children built successfully. "
f"Container will be omitted from the card."
)
logger.debug(f"π No children built for {component_name}")
return None
result = {children_field: built_children}
# Grid has additional params
if component_name == "Grid":
result["columnCount"] = min(3, len(built_children))
final_result = {json_key: result}
logger.debug(
f"π _build_container_generic returning: "
f"{{'{json_key}': {{{repr(children_field)}: [{len(built_children)} items]}}}}"
)
return final_result
def _build_columns_generic(
self,
children: List[Dict],
context: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Build Columns component with nested column structure."""
column_items = []
for child in children:
if child.get("name") != "Column":
continue
col_mult = child.get("multiplier", 1)
col_grandchildren = child.get("children", [])
for _ in range(col_mult):
col_widgets = []
for gc in col_grandchildren:
gc_name = gc.get("name", "")
gc_mult = gc.get("multiplier", 1)
gc_children = gc.get("children", [])
for _ in range(gc_mult):
widget = self._build_widget_generic(
gc_name, gc_children, context
)
if widget:
col_widgets.append(widget)
if col_widgets:
column_items.append({"widgets": col_widgets})
if not column_items:
# Default columns using wrapper components
wrapper = self._get_wrapper()
col1_widget = self._build_component(
"TextParagraph",
{"text": "Column 1"},
wrapper=wrapper,
wrap_with_key=True,
)
col2_widget = self._build_component(
"TextParagraph",
{"text": "Column 2"},
wrapper=wrapper,
wrap_with_key=True,
)
column_items = [
{"widgets": [col1_widget] if col1_widget else []},
{"widgets": [col2_widget] if col2_widget else []},
]
return {"columns": {"columnItems": column_items}}
# =========================================================================
# NOTE: Carousel is built via the generic DSL flow using symbols
# dynamically generated from ModuleWrapper (e.g., β¦[β²ΓN]).
# The wrapper's CarouselCard component handles the structure.
# =========================================================================
def _build_component(
self,
component_name: str,
params: Dict[str, Any],
wrapper: Optional["ComponentMetadataProvider"] = None,
wrap_with_key: bool = False,
children: Optional[List[Dict[str, Any]]] = None,
validate: bool = False,
return_instance: bool = False,
child_instances: Optional[List[Any]] = None,
auto_wrap: bool = False,
target_parent: Optional[str] = None,
) -> Optional[Union[Dict[str, Any], Any]]:
"""Universal component builder leveraging ModuleWrapper DAG and metadata.
This method can build ANY component type by:
1. Using get_cached_class() for fast L1 cache lookup
2. Falling back to wrapper.search() for discovery
3. Using wrapper metadata to validate and enrich params
4. Supporting nested children with DAG validation
5. Using create_card_component() for proper param filtering
6. Auto-wrapping components using DAG relationships (e.g., Button β ButtonList)
Args:
component_name: Component type (e.g., "TextParagraph", "DecoratedText")
params: Parameters for the component
wrapper: Optional ModuleWrapper instance. If None, uses self._get_wrapper()
wrap_with_key: If True, returns {jsonKey: innerDict}. If False, returns innerDict only.
children: Optional pre-built child widgets (JSON dicts) to nest inside this component
validate: If True, validate children against DAG relationships
return_instance: If True, return the component instance instead of rendered JSON.
Use this when building nested structures (e.g., Card with Sections).
child_instances: Optional component instances (not JSON) to pass to container components.
Use with return_instance=True for building full component hierarchies.
auto_wrap: If True, automatically wrap component in required container using DAG.
Example: Button with target_parent=Section β ButtonList containing Button.
target_parent: Target parent component name for auto_wrap. Required if auto_wrap=True.
The DAG will determine if wrapping is needed to make component valid
as a child of target_parent.
Returns:
- If return_instance=True: Component instance (for nesting in parent components)
- If return_instance=False: Widget dict (wrapped or inner based on wrap_with_key)
- None/fallback dict if build fails
"""
wrapper = wrapper or self._get_wrapper()
json_key = self._get_json_key(component_name)
if not wrapper:
# Fallback to simple dict if no wrapper
inner = params.copy()
if children:
inner["widgets"] = children # Generic children field
return {json_key: inner} if wrap_with_key else inner
# 0. Auto-wrap using DAG if requested
# Example: Button with target_parent=Section β ButtonList(buttons=[Button])
if auto_wrap and target_parent:
required_wrapper = self._find_required_wrapper_via_dag(
component_name, target_parent, wrapper
)
if required_wrapper and required_wrapper != component_name:
logger.debug(
f"DAG auto-wrap: {component_name} β {required_wrapper} for {target_parent}"
)
# Build the inner component first
inner_component = self._build_component(
component_name=component_name,
params=params,
wrapper=wrapper,
wrap_with_key=False,
children=children,
validate=validate,
return_instance=return_instance,
child_instances=child_instances,
auto_wrap=False, # Prevent infinite recursion
)
if inner_component is None:
return None
# Build the wrapper container with the inner component as child
if return_instance:
# Return wrapper instance with inner as child_instance
return self._build_component(
component_name=required_wrapper,
params={},
wrapper=wrapper,
wrap_with_key=wrap_with_key,
return_instance=True,
child_instances=[inner_component],
auto_wrap=False,
)
else:
# Build wrapper with inner as JSON child
wrapped_inner = (
{json_key: inner_component}
if not isinstance(inner_component, dict)
or json_key not in inner_component
else inner_component
)
return self._build_component(
component_name=required_wrapper,
params={},
wrapper=wrapper,
wrap_with_key=wrap_with_key,
children=[wrapped_inner],
validate=validate,
auto_wrap=False,
)
# 1. Query wrapper metadata for component info
is_empty = is_empty_component(component_name, wrapper)
children_field = get_children_field(component_name, wrapper)
# 2. Validate children against DAG if requested
if validate and children and hasattr(wrapper, "can_contain"):
for child in children:
# Extract child component name from the widget dict
child_key = (
next(iter(child.keys()), None) if isinstance(child, dict) else None
)
if child_key:
child_name = self._json_key_to_component_name(child_key)
if not wrapper.can_contain(component_name, child_name):
logger.warning(
f"DAG validation: {component_name} cannot contain {child_name}"
)
# 3. Try cached class first (fast L1 memory cache)
comp_class = wrapper.get_cached_class(component_name)
# 4. Fallback to wrapper search if not in cache
if not comp_class and hasattr(wrapper, "search"):
try:
results = wrapper.search(component_name, limit=1)
if results:
result = results[0]
path = result.get("path") or result.get("component_path")
if path:
comp_class = wrapper.get_component_by_path(path)
except Exception as e:
logger.debug(f"Wrapper search failed for {component_name}: {e}")
# 5. Handle empty components (no params needed)
if is_empty:
if return_instance and comp_class:
try:
instance = wrapper.create_card_component(comp_class, {})
if instance:
return instance
except Exception:
pass
result = {json_key: {}}
if children and children_field:
result[json_key][children_field] = children
return result if wrap_with_key else result.get(json_key, {})
# 6. Build via wrapper class if found
if comp_class:
try:
# Merge child_instances into params for container components
build_params = params.copy()
# Pre-process: Convert shorthand params to proper nested structures
# Button/Chip: url -> on_click.open_link.url
if component_name in ("Button", "Chip") and "url" in build_params:
url = build_params.pop("url")
if url and "on_click" not in build_params:
# Build OnClick -> OpenLink structure
try:
from card_framework.v2.widgets.on_click import OnClick
from card_framework.v2.widgets.open_link import OpenLink
build_params["on_click"] = OnClick(
open_link=OpenLink(url=url)
)
except ImportError:
# Fallback: pass as dict (will be handled by create_card_component)
build_params["on_click"] = {"open_link": {"url": url}}
if child_instances:
# Map child instances to appropriate param names based on component type
if component_name == "Card":
# Card expects: header (CardHeader), sections (List[Section])
headers = [
c
for c in child_instances
if type(c).__name__ == "CardHeader"
]
sections = [
c for c in child_instances if type(c).__name__ == "Section"
]
if headers:
build_params["header"] = headers[0]
if sections:
build_params["sections"] = sections
elif component_name == "Section":
# Section expects: widgets (List[Widget])
build_params["widgets"] = child_instances
elif component_name in ("ButtonList", "ChipList"):
# Container expects: buttons/chips list
build_params[children_field or "buttons"] = child_instances
elif component_name == "Columns":
# Columns expects: columnItems (List[Column])
build_params["column_items"] = child_instances
elif component_name == "Grid":
# Grid expects: items (List[GridItem])
build_params["items"] = child_instances
elif component_name == "Carousel":
# Carousel expects: carouselCards (List[CarouselCard])
build_params["carousel_cards"] = child_instances
instance = wrapper.create_card_component(comp_class, build_params)
if instance:
# Pre-process: Set fields on the instance that the wrapper class may not handle
if component_name == "DecoratedText":
# Ensure wrapText is set on the instance
if hasattr(instance, "wrap_text"):
# wrap_text is the Python attribute name (snake_case)
instance.wrap_text = params.get(
"wrapText", params.get("wrap_text", True)
)
# Return instance if requested (for nesting in parent components)
if return_instance:
return instance
# Prefer render() for full widget output, fallback to to_dict()
if hasattr(instance, "render"):
rendered = instance.render()
converted = self._convert_to_camel_case(rendered)
elif hasattr(instance, "to_dict"):
rendered = instance.to_dict()
converted = self._convert_to_camel_case(rendered)
else:
converted = None
if converted:
# Handle already-wrapped vs inner dict
if json_key in converted and len(converted) == 1:
inner = converted[json_key]
else:
inner = converted
# Post-process: Add fields the wrapper class doesn't support
# but Google Chat API needs
if component_name == "DecoratedText":
# wrapText defaults to True unless explicitly False
inner["wrapText"] = params.get("wrapText", True)
# Handle icon -> startIcon conversion
if params.get("icon") and "startIcon" not in inner:
inner["startIcon"] = {
"materialIcon": {"name": params["icon"]}
}
# Handle label fields
if params.get("top_label") and "topLabel" not in inner:
inner["topLabel"] = params["top_label"]
if (
params.get("bottom_label")
and "bottomLabel" not in inner
):
inner["bottomLabel"] = params["bottom_label"]
# Add children if provided (JSON dicts, not instances)
if children and children_field:
# Unwrap array items (e.g., CarouselCard in carouselCards)
from gchat.card_builder.rendering import (
prepare_children_for_container,
)
inner[children_field] = prepare_children_for_container(
component_name, children, children_field
)
return {json_key: inner} if wrap_with_key else inner
except Exception as e:
logger.debug(f"Component build failed for {component_name}: {e}")
# 7. Fallback: build from params directly
inner = params.copy()
if children and children_field:
# Unwrap array items (e.g., CarouselCard in carouselCards)
from gchat.card_builder.rendering import prepare_children_for_container
inner[children_field] = prepare_children_for_container(
component_name, children, children_field
)
elif children:
# Use default children field based on component type
default_field = "widgets" if component_name in ("Section",) else "buttons"
inner[default_field] = children
return {json_key: inner} if wrap_with_key else inner
def _find_required_wrapper_via_dag(
self,
component: str,
target_parent: str,
wrapper: Optional["ComponentMetadataProvider"] = None,
) -> Optional[str]:
"""Find the required wrapper for a component to be valid in target_parent.
Uses the DAG and registered metadata to determine if wrapping is needed:
1. Check if component is a valid direct child of target_parent
2. If not, check for explicit wrapper requirement
3. If not found, try common naming pattern (Button β ButtonList)
4. Fall back to DAG path-finding
Args:
component: Component to potentially wrap (e.g., "Button")
target_parent: Target container (e.g., "Section")
wrapper: ModuleWrapper with DAG capabilities
Returns:
Required wrapper component name, or None if no wrapping needed
Example:
>>> _find_required_wrapper_via_dag("Button", "Section")
"ButtonList" # Button needs ButtonList wrapper to go in Section
>>> _find_required_wrapper_via_dag("DecoratedText", "Section")
None # DecoratedText can go directly in Section
"""
wrapper = wrapper or self._get_wrapper()
if not wrapper:
return None
# Get valid children for target_parent (uses DAG + widget types registry)
valid_children = []
if hasattr(wrapper, "get_valid_children_for_parent"):
valid_children = wrapper.get_valid_children_for_parent(target_parent)
# 1. Check if component is already a valid direct child (no wrapper needed)
if component in valid_children:
return None
# Also check can_contain for direct containment
if hasattr(wrapper, "can_contain"):
if wrapper.can_contain(target_parent, component, direct_only=True):
return None
# 2. Check for explicit wrapper requirement registration
if hasattr(wrapper, "get_required_wrapper"):
explicit_wrapper = wrapper.get_required_wrapper(component)
if explicit_wrapper:
# Verify the wrapper is a valid child of target_parent
if explicit_wrapper in valid_children:
return explicit_wrapper
# Also check via can_contain
if hasattr(wrapper, "can_contain"):
if wrapper.can_contain(
target_parent, explicit_wrapper, direct_only=False
):
return explicit_wrapper
# 3. Try common naming pattern: Component β ComponentList
wrapper_name = f"{component}List"
if wrapper_name in valid_children:
return wrapper_name
# 4. Use DAG path-finding as fallback
if hasattr(wrapper, "get_path"):
path = wrapper.get_path(target_parent, component)
if path and len(path) >= 3:
# Path is [target_parent, ..., wrapper, component]
# The wrapper is the component right before the target
intermediate = path[-2] # Second-to-last is the direct parent
if intermediate != target_parent and intermediate in valid_children:
return intermediate
return None
def _json_key_to_component_name(self, json_key: str) -> str:
"""Convert JSON key to component name.
Delegates to gchat.card_builder.rendering.json_key_to_component_name().
E.g., 'decoratedText' -> 'DecoratedText'
"""
from gchat.card_builder.rendering import json_key_to_component_name
return json_key_to_component_name(json_key)
def build_component_tree(
self,
tree: Dict[str, Any],
validate: bool = True,
) -> Optional[Dict[str, Any]]:
"""Build a complete component tree from a nested structure.
This method recursively builds a component hierarchy using the ModuleWrapper
DAG for validation and metadata queries. It's the high-level entry point
for building any component structure.
Args:
tree: Nested structure like:
{
"component": "Section",
"params": {"header": "My Section"},
"children": [
{"component": "DecoratedText", "params": {"text": "Hello"}},
{"component": "ButtonList", "children": [
{"component": "Button", "params": {"text": "Click", "url": "..."}}
]}
]
}
validate: If True, validate parent-child relationships against DAG
Returns:
Built widget dict in Google Chat format, or None if build fails.
Example:
tree = {
"component": "Section",
"children": [
{"component": "DecoratedText", "params": {"text": "Status: OK", "wrapText": True}},
{"component": "ButtonList", "children": [
{"component": "Button", "params": {"text": "Refresh", "url": "https://..."}}
]}
]
}
result = builder.build_component_tree(tree)
# Returns: {"section": {"widgets": [{"decoratedText": {...}}, {"buttonList": {...}}]}}
"""
wrapper = self._get_wrapper()
component_name = tree.get("component")
params = tree.get("params", {}).copy()
child_trees = tree.get("children", [])
if not component_name:
logger.warning("build_component_tree: missing 'component' key")
return None
# Recursively build children first
built_children = []
if child_trees:
for child_tree in child_trees:
child_widget = self.build_component_tree(child_tree, validate=validate)
if child_widget:
built_children.append(child_widget)
# Build this component with its children
# (_build_component queries children_field internally via get_children_field)
return self._build_component(
component_name=component_name,
params=params,
wrapper=wrapper,
wrap_with_key=True,
children=built_children if built_children else None,
validate=validate,
)
def _build_widgets(
self,
children: List[Dict],
buttons: Optional[List[Dict]],
image_url: Optional[str],
content_texts: List[Dict],
context: Optional[Dict[str, Any]] = None,
) -> List[Dict]:
"""Build widgets from DSL children using GENERIC mapping-driven approach.
This method uses _build_widget_generic for ALL component types, avoiding
if/elif chains. Component behavior is determined by querying the ModuleWrapper:
- get_context_resource(): Which resources each component consumes
- get_children_field(): How container components store children
- is_form_component(): Components needing name fields
- is_empty_component(): Structure-only components
Supports nested DSL notation (e.g., DecoratedText[Button]) via
_map_children_to_params which uses wrapper relationship metadata.
Args:
children: Parsed child components from DSL
buttons: List of button dicts from card_params
image_url: Image URL for image widgets
content_texts: Parsed content texts with styling
context: Shared context for unified resource consumption
Note: Resource consumption is tracked in context (_button_index, _text_index)
so resources used in nested DSL aren't reused elsewhere.
"""
widgets = []
# Use shared context or create local one
if context is None:
context = {
"buttons": buttons or [],
"chips": [], # Chips should come from shared context
"image_url": image_url,
"content_texts": content_texts,
"_button_index": 0,
"_chip_index": 0,
"_text_index": 0,
"_input_index": 0,
}
for child in children:
child_name = child.get("name", "")
multiplier = child.get("multiplier", 1)
grandchildren = child.get("children", [])
for _ in range(multiplier):
# GENERIC: Build any widget using mapping-driven approach
widget = self._build_widget_generic(child_name, grandchildren, context)
if widget:
widgets.append(widget)
return widgets
def _get_default_params(self, component_name: str, index: int = 0) -> Dict:
"""Get minimal default params for a component type. Delegates to card_builder.constants."""
from gchat.card_builder.constants import get_default_params
return get_default_params(component_name, index)
# =========================================================================
# GENERIC CHILD MAPPING (Nested DSL Support)
# =========================================================================
# These methods use ModuleWrapper relationship metadata to generically
# map DSL children to parent component parameters.
def _map_children_to_params(
self,
parent_name: str,
children: List[Dict],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""
Map parsed DSL children to parent component parameters.
Uses module_wrapper relationships to determine which field each
child component should be assigned to in the parent.
Args:
parent_name: Parent component name (e.g., "DecoratedText")
children: List of parsed child dicts from DSL (e.g., [{"name": "Button", ...}])
context: Rendering context (buttons, content_texts, etc.)
Returns:
Dict of {field_name: built_child_widget} to merge into parent params
Example:
children = [{"name": "Button", "params": {}}, {"name": "Icon", "params": {}}]
result = _map_children_to_params("DecoratedText", children, context)
# Returns: {"button": {...button widget...}, "icon": {...icon widget...}}
"""
wrapper = self._get_wrapper()
if not wrapper:
return {}
params = {}
for child in children:
child_name = child.get("name", "")
child_params = child.get("params", {})
child_grandchildren = child.get("children", [])
# Use wrapper relationship lookup to find the correct field
field_info = wrapper.get_field_for_child(parent_name, child_name)
if not field_info:
logger.debug(
f"No field mapping for {parent_name} -> {child_name}, skipping"
)
continue
field_name = field_info["field_name"]
# Convert Python field name to Google Chat JSON field name if needed
from gchat.card_builder.constants import FIELD_NAME_TO_JSON
json_field_name = FIELD_NAME_TO_JSON.get(
(parent_name, field_name), field_name
)
# Resolve child params from context and pattern lookup
resolved_params = self._resolve_child_params(
child_name, child_params, context
)
# Build the child widget
child_widget = self._build_child_widget(
child_name, resolved_params, child_grandchildren, context
)
if child_widget is not None:
params[json_field_name] = child_widget
logger.debug(f"Mapped {child_name} to {parent_name}.{json_field_name}")
else:
warn_strict(
f"_map_children_to_params('{parent_name}'): child '{child_name}' "
f"built to None, skipped in parent params."
)
return params
def _resolve_child_params(
self,
child_name: str,
explicit_params: Dict[str, Any],
context: Dict[str, Any],
) -> Dict[str, Any]:
"""
Resolve parameters for a child component using MAPPING-DRIVEN approach.
Uses wrapper.get_context_resource() and component defaults mapping.
No if/elif chains for specific component types.
Args:
child_name: Component name (e.g., "Button", "Icon")
explicit_params: Params explicitly provided in DSL
context: Rendering context with buttons, texts, etc.
Returns:
Merged params dict ready for component building
"""
params = {}
# Priority order (lowest to highest):
# 1. Defaults from _get_default_params
# 2. Component-specific defaults
# 3. Context-consumed values (e.g., from buttons list)
# 4. Explicit params from DSL
# 1. Start with defaults for this component type
defaults = self._get_default_params(child_name)
params.update(defaults)
# 2. Component-specific defaults (mapping-driven, not if/elif)
component_defaults = {
"Icon": {"known_icon": "STAR"},
"SwitchControl": {"name": "switch", "selected": False},
}
if child_name in component_defaults:
params.update(component_defaults[child_name])
# 3. Context-consumed values OVERRIDE defaults
consumed = self._consume_from_context(child_name, context)
params.update(consumed)
# 4. Explicit params override everything
params.update(explicit_params)
return params
def _convert_to_camel_case(self, data: Any) -> Any:
"""Convert snake_case keys to camelCase for Google Chat API.
Delegates to gchat.card_builder.rendering.convert_to_camel_case().
The wrapper renders snake_case (e.g., start_icon, on_click) but
Google Chat API expects camelCase (e.g., startIcon, onClick).
"""
from gchat.card_builder.rendering import convert_to_camel_case
return convert_to_camel_case(data)
def _build_child_widget(
self,
child_name: str,
params: Dict[str, Any],
grandchildren: List[Dict],
context: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""
Build a child widget using MAPPING-DRIVEN approach (no if/elif chains).
Uses a builder registry to dispatch to specialized builders where needed,
falling back to generic wrapper rendering for all other components.
Args:
child_name: Component name (e.g., "Button", "Icon")
params: Resolved params for this component
grandchildren: Any nested children (for recursive structures)
context: Rendering context
Returns:
Widget dict in Google Chat camelCase format, or None if build fails
"""
_ = grandchildren, context # Reserved for future recursive use
wrapper = self._get_wrapper()
if not wrapper:
logger.warning(f"No wrapper available for building {child_name}")
return None
# Mapping of component names to specialized builders
# These handle components with special requirements (enums, nested objects)
specialized_builders = {
"Button": self._build_button_via_wrapper,
"Icon": self._build_icon_via_wrapper,
"SwitchControl": self._build_switch_via_wrapper,
"OnClick": self._build_onclick_via_wrapper,
}
try:
# Use specialized builder if available, otherwise generic
builder = specialized_builders.get(child_name)
if builder:
return builder(wrapper, params)
return self._build_component(child_name, params, wrapper=wrapper)
except Exception as e:
logger.warning(f"Failed to build {child_name} via wrapper: {e}")
return None
def _build_button_via_wrapper(
self, wrapper, params: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Build a Button using wrapper component classes. Delegates to card_builder.rendering."""
from gchat.card_builder.rendering import build_button_via_wrapper
return build_button_via_wrapper(wrapper, params)
def _build_icon_via_wrapper(
self, wrapper, params: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Build an Icon using wrapper component classes. Delegates to card_builder.rendering."""
from gchat.card_builder.rendering import build_icon_via_wrapper
return build_icon_via_wrapper(wrapper, params)
def _build_switch_via_wrapper(
self, wrapper, params: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Build a SwitchControl. Delegates to card_builder.rendering."""
from gchat.card_builder.rendering import build_switch_via_wrapper
return build_switch_via_wrapper(wrapper, params)
def _build_onclick_via_wrapper(
self, wrapper, params: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Build an OnClick. Delegates to card_builder.rendering."""
from gchat.card_builder.rendering import build_onclick_via_wrapper
return build_onclick_via_wrapper(wrapper, params)
# =========================================================================
# MODULAR FEEDBACK SECTION
# =========================================================================
# Dynamically assembles feedback widgets from component registries.
# Each feedback widget needs: text (prompt) + clickable (callback)
# Components are randomly selected and assembled per card.
def _has_feedback(self, card: Dict) -> bool:
"""Check if card already has feedback content. Delegates to card_builder.validation."""
from gchat.card_builder.validation import has_feedback
return has_feedback(card)
def _clean_card_metadata(self, obj: Any) -> Any:
"""Remove underscore-prefixed keys. Delegates to card_builder.validation."""
from gchat.card_builder.validation import clean_card_metadata
return clean_card_metadata(obj)
def validate_content(self, card_dict: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Validate card content. Delegates to card_builder.validation."""
from gchat.card_builder.validation import validate_content
return validate_content(card_dict)
def generate_dsl_notation(self, card: Dict[str, Any]) -> Optional[str]:
"""Generate DSL notation from a rendered card structure.
Delegates to gchat.card_builder.dsl.generate_dsl_notation().
Args:
card: Card dict in Google Chat format (with sections/widgets)
Returns:
DSL string using symbols from ModuleWrapper, or None if unable to generate
"""
try:
wrapper = self._get_wrapper()
if not wrapper:
return None
from gchat.card_builder.dsl import generate_dsl_notation
return generate_dsl_notation(card, wrapper.symbol_mapping)
except Exception as e:
logger.debug(f"Failed to generate DSL from card: {e}")
return None
def _get_feedback_base_url(self) -> str:
"""Get the feedback base URL from settings.
Uses the server's base_url with /card-feedback endpoint.
Falls back to placeholder only if base_url is not configured.
"""
# Use the server's base_url (e.g., https://localhost:8002)
base_url = getattr(_settings, "base_url", "")
if base_url:
return f"{base_url}/card-feedback"
return "https://feedback.example.com"
def _make_callback_url(
self, card_id: str, feedback_val: str, feedback_type: str
) -> str:
"""Create feedback callback URL."""
base_url = self._get_feedback_base_url()
return f"{base_url}?card_id={card_id}&feedback={feedback_val}&feedback_type={feedback_type}"
@fire_and_forget
def _store_card_pattern(
self,
card_id: str,
description: str,
title: Optional[str],
structure_dsl: Optional[str],
card: Dict[str, Any],
description_rendered: Optional[str] = None,
jinja_applied: bool = False,
) -> None:
"""
Store the main card content pattern in Qdrant (fire-and-forget).
Runs in background thread to avoid blocking card building.
Use _store_card_pattern.sync() for synchronous testing.
This stores ONLY the main card content (pattern_type="content"),
NOT the feedback UI section which is stored separately.
Args:
card_id: Unique ID for this card
description: Original card description (RAW, before Jinja processing)
title: Card title
structure_dsl: DSL structure if parsed
card: The built card dict (without feedback section)
description_rendered: Jinja-processed description (if different from raw)
jinja_applied: Whether Jinja template processing was applied
"""
if not ENABLE_FEEDBACK_BUTTONS:
return # Skip if feedback is disabled
try:
from gchat.feedback_loop import get_feedback_loop
feedback_loop = get_feedback_loop()
# Extract component paths from card structure
component_paths = self._extract_component_paths(card)
# Build instance params from card content
# Stores BOTH raw and processed text for pattern reuse
instance_params = {
"title": title,
"description": description[:500] if description else "",
"dsl": structure_dsl,
"component_count": len(component_paths),
# Store Jinja-processed output for analytics/debugging
"description_rendered": (
description_rendered[:500] if description_rendered else None
),
# Track whether Jinja actually transformed the text
"jinja_applied": jinja_applied,
}
# Extract style metadata from rendered description (or raw if no rendering)
style_metadata = extract_style_metadata(description_rendered or description)
# Also scan instance_params for any text fields with Jinja
for key, value in instance_params.items():
if isinstance(value, str) and "{{" in value:
additional_styles = extract_style_metadata(value)
for style_field in [
"jinja_filters",
"colors",
"semantic_styles",
"formatting",
]:
style_metadata[style_field].extend(
additional_styles.get(style_field, [])
)
# Deduplicate after merging
for key in style_metadata:
style_metadata[key] = list(set(style_metadata[key]))
# Store style metadata with instance params
instance_params["style_metadata"] = style_metadata
# Store the CONTENT pattern (main card, not feedback UI)
point_id = feedback_loop.store_instance_pattern(
card_description=description,
component_paths=component_paths,
instance_params=instance_params,
card_id=card_id,
structure_description=structure_dsl or "",
pattern_type="content", # Tag as main content
)
if point_id:
logger.debug(
f"π¦ Stored content pattern: {card_id[:8]}... -> {point_id[:8]}..."
)
if jinja_applied:
logger.debug(
f" π¨ Jinja rendered: '{description[:30]}...' -> '{description_rendered[:30] if description_rendered else ''}...'"
)
if style_metadata.get("semantic_styles") or style_metadata.get(
"formatting"
):
logger.debug(f" π¨ Style metadata: {style_metadata}")
except Exception as e:
# Don't fail card generation if pattern storage fails
logger.warning(f"β οΈ Failed to store card pattern: {e}")
@fire_and_forget
def _store_feedback_ui_pattern(
self,
card_id: str,
feedback_section: Dict[str, Any],
) -> None:
"""
Store the feedback UI section pattern in Qdrant (fire-and-forget).
Runs in background thread to avoid blocking card building.
Use _store_feedback_ui_pattern.sync() for synchronous testing.
This stores the randomly generated feedback UI (pattern_type="feedback_ui"),
allowing learning of which feedback layouts work best.
Args:
card_id: Unique ID for this card (links content + feedback_ui patterns)
feedback_section: The feedback section dict with _feedback_assembly metadata
"""
if not ENABLE_FEEDBACK_BUTTONS:
return
try:
from gchat.feedback_loop import get_feedback_loop
feedback_loop = get_feedback_loop()
# Get assembly metadata
assembly = feedback_section.get("_feedback_assembly", {})
if not assembly:
return # No metadata to store
# Build description from assembly
description = (
f"Feedback UI: {assembly.get('content_text', '?')} + "
f"{assembly.get('form_text', '?')} in {assembly.get('layout', '?')} layout"
)
# Extract component paths from feedback widgets
# Map camelCase JSON keys to PascalCase component names
json_key_to_component = {
"textParagraph": "TextParagraph",
"decoratedText": "DecoratedText",
"buttonList": "ButtonList",
"chipList": "ChipList",
"columns": "Columns",
"divider": "Divider",
}
component_paths = []
for widget in feedback_section.get("widgets", []):
for key in widget.keys():
if key in json_key_to_component:
component_paths.append(json_key_to_component[key])
# Store the FEEDBACK_UI pattern
point_id = feedback_loop.store_instance_pattern(
card_description=description,
component_paths=component_paths,
instance_params=assembly, # Store the assembly metadata
card_id=f"{card_id}_feedback", # Link to parent card
structure_description=description,
pattern_type="feedback_ui", # Tag as feedback UI
)
if point_id:
logger.debug(
f"π¦ Stored feedback_ui pattern: {card_id[:8]}... -> {point_id[:8]}..."
)
except Exception as e:
logger.warning(f"β οΈ Failed to store feedback_ui pattern: {e}")
# OPTIMIAED MAKE DYNMAIC NOT PREDEFINED DUAL COMPONENT BUILDERS (text + click in one widget)
def _extract_component_paths(self, card: Dict[str, Any]) -> List[str]:
"""Extract component paths from a card structure.
Delegates to gchat.card_builder.dsl.extract_component_paths().
Returns:
List of component names found (e.g., ["Section", "TextParagraph", "ButtonList"])
"""
from gchat.card_builder.dsl import extract_component_paths
return extract_component_paths(card)
# -------------------------------------------------------------------------
# GENERIC FEEDBACK WIDGET BUILDER (Config-Driven)
# Uses wrapper components and .render() for all feedback widgets
# -------------------------------------------------------------------------
def _build_feedback_widget(
self,
component_name: str,
params: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Build a feedback widget using the unified _build_component method.
Thin wrapper that ensures wrap_with_key=True for feedback widgets,
which need the full {jsonKey: innerDict} format.
Args:
component_name: Component type (e.g., "TextParagraph", "DecoratedText")
params: Parameters for the component
Returns:
Widget dict in Google Chat format: {jsonKey: innerDict}
"""
return self._build_component(component_name, params, wrap_with_key=True)
# -------------------------------------------------------------------------
# TEXT COMPONENT BUILDERS
# Each uses _build_feedback_widget with wrapper components
# -------------------------------------------------------------------------
def _style_feedback_keyword(self, keyword: str, style: str) -> str:
"""Apply styling to a feedback keyword using HTML.
Delegates to gchat.card_builder.jinja_styling.style_keyword().
"""
from gchat.card_builder.jinja_styling import style_keyword
return style_keyword(keyword, style, FEEDBACK_COLORS)
def _build_styled_feedback_prompt(self, prompt_tuple: tuple) -> str:
"""
Build a feedback prompt with randomly styled keyword.
Args:
prompt_tuple: (template, keyword) from CONTENT_FEEDBACK_PROMPTS or FORM_FEEDBACK_PROMPTS
Returns:
Formatted prompt string with styled keyword
"""
template, keyword = prompt_tuple
style = random.choice(FEEDBACK_TEXT_STYLES)
styled_keyword = self._style_feedback_keyword(keyword, style)
return template.format(keyword=styled_keyword)
def _build_text_feedback(self, text_type: str, text: str, **kwargs) -> Dict:
"""Unified config-driven text builder - replaces 5 individual text methods.
Uses TEXT_CONFIGS to determine component type and formatting.
Args:
text_type: Key in TEXT_CONFIGS (e.g., "text_paragraph", "decorated_text")
text: Text content to display
**kwargs: Additional params (e.g., label for decorated_text_labeled)
Returns:
Widget dict (e.g., {"textParagraph": {"text": "..."}})
"""
config = TEXT_CONFIGS.get(text_type)
if not config:
# Fallback to simple text paragraph
config = TEXT_CONFIGS["text_paragraph"]
# Handle direct dict components (chip_text)
if config.get("direct_dict"):
return {"chipList": {"chips": [{"label": text, "enabled": False}]}}
# Apply text formatting if configured
format_fn = config.get("format_text")
formatted_text = format_fn(text) if format_fn else text
# Build params
component_name = config["component"]
params = {"text": formatted_text}
if config.get("wrap_text"):
params["wrap_text"] = True
if config.get("top_label") or kwargs.get("label"):
params["top_label"] = kwargs.get("label") or config.get("top_label")
# Build widget
widget = self._build_feedback_widget(component_name, params)
# Add icon if configured
if config.get("add_random_icon") and widget and "decoratedText" in widget:
icon_name = random.choice(FEEDBACK_MATERIAL_ICONS)
widget["decoratedText"]["startIcon"] = self._build_start_icon(icon_name)
return widget
# Convenience wrappers for backward compatibility with _create_feedback_section
def _text_paragraph(self, text: str, **_kwargs) -> Dict:
return self._build_text_feedback("text_paragraph", text)
def _text_decorated(self, text: str, **_kwargs) -> Dict:
return self._build_text_feedback("decorated_text", text)
def _text_decorated_icon(self, text: str, **_kwargs) -> Dict:
return self._build_text_feedback("decorated_text_icon", text)
def _text_decorated_labeled(
self, text: str, label: str = "Feedback", **_kwargs
) -> Dict:
return self._build_text_feedback("decorated_text_labeled", text, label=label)
def _text_chip(self, text: str, **_kwargs) -> Dict:
return self._build_text_feedback("chip_text", text)
# -------------------------------------------------------------------------
# CLICKABLE COMPONENT BUILDERS (Config-Driven)
# Uses _build_feedback_button_item for individual buttons/chips
# Business logic (labels, URLs) separated from widget structure
# -------------------------------------------------------------------------
def _build_feedback_button_item(
self,
text: str,
url: str,
icon: Optional[str] = None,
icon_url: Optional[str] = None,
material_icon: Optional[str] = None,
button_type: Optional[str] = None,
) -> Dict[str, Any]:
"""Build a single button item with onClick using wrapper.
Separates business logic (text, url) from widget structure.
Args:
text: Button text label
url: Callback URL for onClick
icon: Known icon name (e.g., "STAR", "BOOKMARK") - legacy
icon_url: URL for custom icon image
material_icon: Material icon name (e.g., "thumb_up") - preferred
button_type: Button style (OUTLINED, FILLED, FILLED_TONAL, BORDERLESS)
"""
wrapper = self._get_wrapper()
if wrapper:
try:
# Get Button and OnClick classes via cache for fast retrieval
Button = wrapper.get_cached_class("Button")
OnClick = wrapper.get_cached_class("OnClick")
OpenLink = wrapper.get_cached_class("OpenLink")
if all([Button, OnClick, OpenLink]):
open_link = OpenLink(url=url)
on_click = OnClick(open_link=open_link)
button = Button(text=text, on_click=on_click)
if hasattr(button, "to_dict"):
btn_dict = self._convert_to_camel_case(button.to_dict())
# Add icon - prefer materialIcon, fallback to knownIcon/iconUrl
if material_icon:
btn_dict["icon"] = self._build_material_icon(material_icon)
elif icon:
btn_dict["icon"] = {"knownIcon": icon}
elif icon_url:
btn_dict["icon"] = {"iconUrl": icon_url}
# Add button type/style
if button_type:
btn_dict["type"] = button_type
return btn_dict
except Exception as e:
logger.debug(f"Wrapper button build failed: {e}")
# Fallback to manual dict
btn = {"text": text, "onClick": {"openLink": {"url": url}}}
if material_icon:
btn["icon"] = {"materialIcon": {"name": material_icon}}
elif icon:
btn["icon"] = {"knownIcon": icon}
elif icon_url:
btn["icon"] = {"iconUrl": icon_url}
if button_type:
btn["type"] = button_type
return btn
def _build_feedback_chip_item(
self,
label: str,
url: str,
) -> Dict[str, Any]:
"""Build a single chip item with onClick using wrapper."""
wrapper = self._get_wrapper()
if wrapper:
try:
# Get Chip and OnClick classes via cache for fast retrieval
Chip = wrapper.get_cached_class("Chip")
OnClick = wrapper.get_cached_class("OnClick")
OpenLink = wrapper.get_cached_class("OpenLink")
if all([Chip, OnClick, OpenLink]):
open_link = OpenLink(url=url)
on_click = OnClick(open_link=open_link)
chip = Chip(label=label, on_click=on_click)
if hasattr(chip, "to_dict"):
return self._convert_to_camel_case(chip.to_dict())
except Exception as e:
logger.debug(f"Wrapper chip build failed: {e}")
# Fallback
return {"label": label, "onClick": {"openLink": {"url": url}}}
def _build_clickable_feedback(
self, handler_type: str, card_id: str, feedback_type: str
) -> Dict:
"""Unified config-driven click handler - replaces 7 individual methods.
Uses CLICK_CONFIGS to determine widget structure, icons, and behavior.
Args:
handler_type: Key in CLICK_CONFIGS (e.g., "button_list", "star_rating")
card_id: Card ID for callback URL
feedback_type: Feedback type for callback URL
Returns:
Widget dict (e.g., {"buttonList": {"buttons": [...]}})
"""
config = CLICK_CONFIGS.get(handler_type)
if not config:
# Fallback to basic button list
config = CLICK_CONFIGS["button_list"]
widget_key = config["widget"]
items_key = config["items_key"]
use_chips = config.get("use_chips", False)
btn_type = config.get("button_type") or random.choice(BUTTON_TYPES)
items = []
if config.get("binary", True):
# Binary positive/negative feedback
pos_label = random.choice(POSITIVE_LABELS)
neg_label = random.choice(NEGATIVE_LABELS)
pos_url = self._make_callback_url(card_id, "positive", feedback_type)
neg_url = self._make_callback_url(card_id, "negative", feedback_type)
# Determine icons
pos_icon = config.get("pos_icon")
neg_icon = config.get("neg_icon")
pos_icon_url = None
neg_icon_url = None
if config.get("pos_icon_source"):
# Random icon from list
icon_list = globals().get(config["pos_icon_source"], [])
pos_icon = random.choice(icon_list) if icon_list else None
if config.get("neg_icon_source"):
icon_list = globals().get(config["neg_icon_source"], [])
neg_icon = random.choice(icon_list) if icon_list else None
if config.get("pos_icon_url_source"):
url_list = globals().get(config["pos_icon_url_source"], [])
pos_icon_url = random.choice(url_list) if url_list else None
if config.get("neg_icon_url_source"):
url_list = globals().get(config["neg_icon_url_source"], [])
neg_icon_url = random.choice(url_list) if url_list else None
if use_chips:
items = [
self._build_feedback_chip_item(pos_label, pos_url),
self._build_feedback_chip_item(neg_label, neg_url),
]
else:
items = [
self._build_feedback_button_item(
pos_label,
pos_url,
material_icon=pos_icon,
icon_url=pos_icon_url,
button_type=btn_type,
),
self._build_feedback_button_item(
neg_label,
neg_url,
material_icon=neg_icon,
icon_url=neg_icon_url,
button_type=btn_type,
),
]
else:
# Multi-option ratings (star, emoji, etc.)
ratings = config.get("ratings", [])
for icon_name, label, rating_value in ratings:
url = self._make_callback_url(card_id, rating_value, feedback_type)
btn = self._build_feedback_button_item(
label, url, material_icon=icon_name, button_type=btn_type
)
items.append(btn)
return {widget_key: {items_key: items}}
# Convenience wrappers for backward compatibility with _create_feedback_section
def _click_button_list(self, card_id: str, feedback_type: str, **_kwargs) -> Dict:
return self._build_clickable_feedback("button_list", card_id, feedback_type)
def _click_chip_list(self, card_id: str, feedback_type: str, **_kwargs) -> Dict:
return self._build_clickable_feedback("chip_list", card_id, feedback_type)
def _click_icon_buttons(self, card_id: str, feedback_type: str, **_kwargs) -> Dict:
return self._build_clickable_feedback("icon_buttons", card_id, feedback_type)
def _click_icon_buttons_alt(
self, card_id: str, feedback_type: str, **_kwargs
) -> Dict:
return self._build_clickable_feedback(
"icon_buttons_alt", card_id, feedback_type
)
def _click_url_image_buttons(
self, card_id: str, feedback_type: str, **_kwargs
) -> Dict:
return self._build_clickable_feedback(
"url_image_buttons", card_id, feedback_type
)
def _click_star_rating(self, card_id: str, feedback_type: str, **_kwargs) -> Dict:
return self._build_clickable_feedback("star_rating", card_id, feedback_type)
def _click_emoji_rating(self, card_id: str, feedback_type: str, **_kwargs) -> Dict:
return self._build_clickable_feedback("emoji_rating", card_id, feedback_type)
# -------------------------------------------------------------------------
# DUAL COMPONENT BUILDERS (text + click in one widget)
# -------------------------------------------------------------------------
def _dual_decorated_with_button(
self, text: str, card_id: str, feedback_type: str, **_kwargs
) -> List[Dict]:
"""Decorated text with inline button + separate negative button - uses wrapper.
Uses Material Icons for visual variety and consistency.
"""
pos_label = random.choice(POSITIVE_LABELS)
neg_label = random.choice(NEGATIVE_LABELS)
pos_url = self._make_callback_url(card_id, "positive", feedback_type)
neg_url = self._make_callback_url(card_id, "negative", feedback_type)
icon_name = random.choice(FEEDBACK_MATERIAL_ICONS)
btn_type = random.choice(BUTTON_TYPES)
# Build decorated text with inline button using wrapper
decorated_widget = self._build_feedback_widget(
"DecoratedText", {"text": text, "wrap_text": True}
)
if decorated_widget and "decoratedText" in decorated_widget:
decorated_widget["decoratedText"]["startIcon"] = self._build_start_icon(
icon_name
)
decorated_widget["decoratedText"]["button"] = (
self._build_feedback_button_item(
pos_label, pos_url, button_type=btn_type
)
)
# Build separate negative button list using wrapper
neg_button = self._build_feedback_button_item(
neg_label, neg_url, button_type=btn_type
)
wrapper = self._get_wrapper()
button_list_widget = self._build_component(
"ButtonList",
{},
wrapper=wrapper,
wrap_with_key=True,
)
# Fallback if wrapper build fails or need to insert button
if button_list_widget and "buttonList" in button_list_widget:
button_list_widget["buttonList"]["buttons"] = [neg_button]
else:
button_list_widget = {"buttonList": {"buttons": [neg_button]}}
return [decorated_widget, button_list_widget]
def _dual_decorated_inline_only(
self, text: str, card_id: str, feedback_type: str, **_kwargs
) -> List[Dict]:
"""
Most compact: DecoratedText with single inline button (1 widget total).
Uses DecoratedText's built-in button property for maximum compactness.
Only shows positive button inline - negative is implied by not clicking.
"""
pos_label = random.choice(POSITIVE_LABELS)
pos_url = self._make_callback_url(card_id, "positive", feedback_type)
btn_type = random.choice(BUTTON_TYPES)
# Build decorated text with inline button
decorated_widget = self._build_feedback_widget(
"DecoratedText", {"text": text, "wrap_text": True}
)
if decorated_widget and "decoratedText" in decorated_widget:
decorated_widget["decoratedText"]["button"] = (
self._build_feedback_button_item(
pos_label, pos_url, button_type=btn_type
)
)
return [decorated_widget]
def _dual_columns_inline(
self, text: str, card_id: str, feedback_type: str, **_kwargs
) -> List[Dict]:
"""Columns with text left, buttons right (all in one widget) - uses wrapper."""
pos_label = random.choice(POSITIVE_LABELS)
neg_label = random.choice(NEGATIVE_LABELS)
pos_url = self._make_callback_url(card_id, "positive", feedback_type)
neg_url = self._make_callback_url(card_id, "negative", feedback_type)
btn_type = random.choice(BUTTON_TYPES)
# Build decorated text widget using wrapper
text_widget = self._build_feedback_widget(
"DecoratedText", {"text": text, "wrap_text": True}
)
# Build buttons using helper and wrapper
buttons = [
self._build_feedback_button_item(pos_label, pos_url, button_type=btn_type),
self._build_feedback_button_item(neg_label, neg_url, button_type=btn_type),
]
wrapper = self._get_wrapper()
button_list_widget = self._build_component(
"ButtonList",
{},
wrapper=wrapper,
wrap_with_key=True,
)
# Insert buttons into the widget
if button_list_widget and "buttonList" in button_list_widget:
button_list_widget["buttonList"]["buttons"] = buttons
else:
button_list_widget = {"buttonList": {"buttons": buttons}}
# Build columns structure
return [
{
"columns": {
"columnItems": [
{
"horizontalSizeStyle": "FILL_AVAILABLE_SPACE",
"horizontalAlignment": "START",
"verticalAlignment": "CENTER",
"widgets": [text_widget],
},
{
"horizontalSizeStyle": "FILL_MINIMUM_SPACE",
"horizontalAlignment": "END",
"verticalAlignment": "CENTER",
"widgets": [button_list_widget],
},
]
}
}
]
# -------------------------------------------------------------------------
# LAYOUT WRAPPERS
# -------------------------------------------------------------------------
def _build_feedback_layout(
self,
layout_type: str,
content_widgets: List[Dict],
form_widgets: List[Dict],
content_first: bool,
) -> List[Dict]:
"""Unified config-driven layout builder - replaces 3 individual layout methods.
Uses LAYOUT_CONFIGS to determine layout behavior.
Args:
layout_type: Key in LAYOUT_CONFIGS (e.g., "sequential", "with_divider")
content_widgets: Content feedback widgets
form_widgets: Form/action feedback widgets
content_first: Whether content should appear first
Returns:
Combined list of widgets in the specified layout
"""
config = LAYOUT_CONFIGS.get(layout_type, {})
# Handle compact layout (groups by type)
if config.get("group_by_type"):
texts = [
w
for w in content_widgets + form_widgets
if not any(k in w for k in ["buttonList", "chipList", "grid"])
]
buttons = [
w
for w in content_widgets + form_widgets
if any(k in w for k in ["buttonList", "chipList", "grid"])
]
return texts + buttons if content_first else buttons + texts
# Standard sequential layout with optional divider
first, second = (
(content_widgets, form_widgets)
if content_first
else (form_widgets, content_widgets)
)
if config.get("add_divider"):
return first + [{"divider": {}}] + second
return first + second
# Convenience wrappers for backward compatibility
def _layout_sequential(
self, content_widgets: List[Dict], form_widgets: List[Dict], content_first: bool
) -> List[Dict]:
return self._build_feedback_layout(
"sequential", content_widgets, form_widgets, content_first
)
def _layout_with_divider(
self, content_widgets: List[Dict], form_widgets: List[Dict], content_first: bool
) -> List[Dict]:
return self._build_feedback_layout(
"with_divider", content_widgets, form_widgets, content_first
)
def _layout_compact(
self, content_widgets: List[Dict], form_widgets: List[Dict], content_first: bool
) -> List[Dict]:
return self._build_feedback_layout(
"compact", content_widgets, form_widgets, content_first
)
# -------------------------------------------------------------------------
# MODULAR ASSEMBLY
# -------------------------------------------------------------------------
def _create_feedback_section(self, card_id: str) -> Dict:
"""
Create feedback section by randomly assembling components.
Assembly process:
1. Select text component type for content feedback
2. Select text component type for form feedback
3. Select clickable component type for content feedback
4. Select clickable component type for form feedback
5. Select layout wrapper
6. Select order (content first vs form first)
This creates massive variety for training data collection.
"""
# Component registries with builder methods
text_builders = {
"text_paragraph": self._text_paragraph,
"decorated_text": self._text_decorated,
"decorated_text_icon": self._text_decorated_icon,
"decorated_text_labeled": self._text_decorated_labeled,
"chip_text": self._text_chip,
}
click_builders = {
"button_list": self._click_button_list,
"chip_list": self._click_chip_list,
"icon_buttons": self._click_icon_buttons,
"icon_buttons_alt": self._click_icon_buttons_alt,
"url_image_buttons": self._click_url_image_buttons,
"star_rating": self._click_star_rating, # 3-star rating with Material Icons
"emoji_rating": self._click_emoji_rating, # Sentiment icons (πππ)
}
dual_builders = {
"decorated_with_button": self._dual_decorated_with_button,
"columns_inline": self._dual_columns_inline,
"decorated_inline_only": self._dual_decorated_inline_only, # Most compact
}
layout_builders = {
"sequential": self._layout_sequential,
"with_divider": self._layout_with_divider,
"compact": self._layout_compact,
}
# Random selections
content_text_type = random.choice(list(text_builders.keys()))
form_text_type = random.choice(list(text_builders.keys()))
content_click_type = random.choice(list(click_builders.keys()))
form_click_type = random.choice(list(click_builders.keys()))
layout_type = random.choice(list(layout_builders.keys()))
content_first = random.choice([True, False])
# Always use collapsible style for feedback section (cleaner UX)
section_style = "collapsible_0"
# Occasionally use dual components (30% chance)
use_dual_content = random.random() < 0.3
use_dual_form = random.random() < 0.3
# Build content feedback widgets with styled prompt
content_prompt_tuple = random.choice(CONTENT_FEEDBACK_PROMPTS)
content_prompt = self._build_styled_feedback_prompt(content_prompt_tuple)
content_style = content_prompt_tuple[1] # Track keyword for metadata
if use_dual_content:
dual_type = random.choice(list(dual_builders.keys()))
# Dual builders now return lists
content_widgets = dual_builders[dual_type](
content_prompt, card_id, "content"
)
content_text_type = f"dual:{dual_type}"
content_click_type = f"dual:{dual_type}"
else:
content_widgets = [
text_builders[content_text_type](content_prompt, label="Content"),
click_builders[content_click_type](card_id, "content"),
]
# Build form feedback widgets with styled prompt
form_prompt_tuple = random.choice(FORM_FEEDBACK_PROMPTS)
form_prompt = self._build_styled_feedback_prompt(form_prompt_tuple)
form_style = form_prompt_tuple[1] # Track keyword for metadata
if use_dual_form:
dual_type = random.choice(list(dual_builders.keys()))
# Dual builders now return lists
form_widgets = dual_builders[dual_type](form_prompt, card_id, "form")
form_text_type = f"dual:{dual_type}"
form_click_type = f"dual:{dual_type}"
else:
form_widgets = [
text_builders[form_text_type](form_prompt, label="Layout"),
click_builders[form_click_type](card_id, "form"),
]
# Apply layout
widgets = layout_builders[layout_type](
content_widgets, form_widgets, content_first
)
# Build metadata for training
assembly_metadata = {
"content_text": content_text_type,
"content_click": content_click_type,
"content_keyword": content_style, # e.g., "content", "data", "values"
"form_text": form_text_type,
"form_click": form_click_type,
"form_keyword": form_style, # e.g., "layout", "structure", "design"
"layout": layout_type,
"content_first": content_first,
"section_style": section_style,
}
logger.debug(f"π² Feedback assembly: {assembly_metadata}")
# Build section with collapsible style and custom expand/collapse buttons
section = {
"widgets": widgets,
"collapsible": True,
"uncollapsibleWidgetsCount": 0, # All hidden by default (most compact)
"collapseControl": {
"horizontalAlignment": "START",
"expandButton": {
"text": "Share Card Feedback",
"icon": {
"materialIcon": {"name": "arrow_cool_down"},
},
"type": "BORDERLESS",
},
"collapseButton": {
"text": "Hide Feedback",
"icon": {
"materialIcon": {"name": "keyboard_double_arrow_up"},
},
"type": "BORDERLESS",
},
},
"_feedback_assembly": assembly_metadata,
}
return section
# =========================================================================
# MAIN BUILD METHOD
# =========================================================================
def build(
self,
description: str,
title: Optional[str] = None,
subtitle: Optional[str] = None,
buttons: Optional[List[Dict]] = None,
chips: Optional[List[Dict]] = None,
image_url: Optional[str] = None,
text: Optional[str] = None,
items: Optional[List[Dict]] = None,
cards: Optional[List[Dict]] = None,
) -> Dict[str, Any]:
"""
Build a card from DSL description.
Args:
description: Card description with optional DSL notation
Structure DSL: Symbol[ChildΓN] pattern (symbols from ModuleWrapper)
Carousel DSL: Use symbols from ModuleWrapper (query wrapper for current mappings)
Content DSL: <symbol> 'text' style_modifier
title: Card header title
subtitle: Card header subtitle
buttons: Explicit button list (overrides Content DSL buttons)
chips: Explicit chip list [{label, url, icon}, ...]
image_url: Image URL for image widgets
text: Explicit text content for text widgets (used if no Content DSL texts)
items: Array of decorated text items [{text, icon, top_label}, ...]
cards: Carousel cards list [{title, subtitle, image_url, text, buttons}, ...]
Returns:
Card dict in Google Chat format
"""
card_id = str(uuid.uuid4())[:8]
card = None
# Reset Jinja tracking for this build
self._jinja_applied = False
# Track the Jinja-rendered description (if different from raw)
self._description_rendered: Optional[str] = None
# Pre-process description through Jinja to capture rendered version
# This allows us to store both raw and processed text in the pattern
if description:
processed_desc = self._format_text_for_chat(description)
if processed_desc != description:
self._description_rendered = processed_desc
# Collect all params for pattern detection
all_params = {
"title": title,
"subtitle": subtitle,
"buttons": buttons,
"chips": chips,
"image_url": image_url,
"text": text,
"items": items,
"cards": cards, # Carousel cards
}
# Try DSL-based building first (handles both regular cards and carousels)
# DSL symbols are dynamically generated from ModuleWrapper
structure_dsl = self._extract_structure_dsl(description)
if structure_dsl:
logger.info(f"π£ Found Structure DSL: {structure_dsl}")
# Auto-apply styles from similar patterns (before DSL build)
# This allows DSL cards to inherit styles from previously successful cards
styled_text = text
styled_items = items
# Query pattern for style_metadata
pattern = self._query_qdrant_patterns(description, all_params)
style_metadata = {}
if pattern:
style_metadata = pattern.get("instance_params", {}).get(
"style_metadata", {}
)
if style_metadata.get("semantic_styles") or style_metadata.get(
"jinja_filters"
):
logger.info(
f"π¨ Found style_metadata for auto-styling: {style_metadata}"
)
# Apply styles to text
if text and not self._has_explicit_styles({"text": text}):
styled_text = self._apply_style_to_text(text, style_metadata)
if styled_text != text:
logger.info(
f"π¨ Styled text: {text[:30]}... -> {styled_text[:50]}..."
)
# Apply styles to each item in items array
if items:
styled_items = []
for item in items:
styled_item = dict(item) # Copy to avoid mutation
if item.get("text") and not self._has_explicit_styles(
{"text": item["text"]}
):
styled_item["text"] = self._apply_style_to_text(
item["text"], style_metadata
)
if item.get("top_label") and not self._has_explicit_styles(
{"text": item["top_label"]}
):
styled_item["top_label"] = self._apply_style_to_text(
item["top_label"], style_metadata
)
if item.get("bottom_label") and not self._has_explicit_styles(
{"text": item["bottom_label"]}
):
styled_item["bottom_label"] = self._apply_style_to_text(
item["bottom_label"], style_metadata
)
styled_items.append(styled_item)
logger.info(f"π¨ Styled {len(items)} items")
card = self._build_from_dsl(
structure_dsl=structure_dsl,
description=description,
title=title,
subtitle=subtitle,
buttons=buttons,
chips=chips,
image_url=image_url,
text=styled_text,
items=styled_items,
cards=cards,
)
# Fallback: Try Content DSL only (no structure)
if not card:
content_dsl = self._parse_content_dsl(description)
if content_dsl and content_dsl.get("texts"):
logger.info("π¨ Building from Content DSL only")
sections = []
# Create context for generic builders
context = {
"content_texts": content_dsl.get("texts", []),
"buttons": content_dsl.get("buttons", []),
"chips": chips or [],
"_text_index": 0,
"_button_index": 0,
"_chip_index": 0,
"_input_index": 0,
}
# Build text widgets using generic builder
for _ in content_dsl["texts"]:
widget = self._build_widget_generic("DecoratedText", [], context)
if widget:
sections.append({"widgets": [widget]})
# Build buttons using generic builder
if content_dsl.get("buttons"):
# Create ButtonList children spec for the number of buttons
button_children = [
{"name": "Button", "multiplier": len(content_dsl["buttons"])}
]
widget = self._build_widget_generic(
"ButtonList", button_children, context
)
if widget:
sections.append({"widgets": [widget]})
if sections:
card = {"sections": sections}
if title:
card["header"] = {"title": title}
if subtitle:
card["header"]["subtitle"] = subtitle
# Query Qdrant for matching instance patterns
if not card:
card_params = {
"title": title,
"subtitle": subtitle,
"buttons": buttons,
"image_url": image_url,
"text": description, # Include description as text for widget content
}
pattern = self._query_qdrant_patterns(description, card_params)
if pattern:
logger.info(
f"π― Found Qdrant pattern match: {pattern.get('structure_description', '')[:50]}..."
)
card = self._build_from_pattern(pattern, card_params)
# Generate pattern on-the-fly if Qdrant has no match
if not card:
card_params = {
"title": title,
"subtitle": subtitle,
"buttons": buttons,
"image_url": image_url,
"text": description, # Include description as text for widget content
}
logger.info("π§ Generating pattern from ModuleWrapper relationships")
pattern = self._generate_pattern_from_wrapper(description, card_params)
card = self._build_from_pattern(pattern, card_params)
# Fallback: Simple card from description (last resort)
if not card:
logger.info("π Building simple card from description (fallback)")
# Build text widget using wrapper for consistency
wrapper = self._get_wrapper()
text_widget = self._build_component(
"TextParagraph",
{"text": description},
wrapper=wrapper,
wrap_with_key=True,
)
if text_widget:
card = {"sections": [{"widgets": [text_widget]}]}
else:
# Ultimate fallback if wrapper fails
processed_text = self._format_text_for_chat(description)
card = {
"sections": [
{"widgets": [{"textParagraph": {"text": processed_text}}]}
]
}
if title:
card["header"] = {"title": title}
if subtitle:
card["header"]["subtitle"] = subtitle
# Store metadata for debugging (will be cleaned before return)
card["_card_id"] = card_id
if structure_dsl:
card["_dsl_structure"] = structure_dsl
# Track if Jinja template processing was applied
if self._jinja_applied:
card["_jinja_applied"] = True
logger.info("π¨ Jinja template styling applied to card content")
# IMPORTANT: Store instance pattern BEFORE adding feedback section
# This keeps the main card pattern (from tool inference) separate from
# the randomly generated feedback UI. Only the meaningful content is stored.
# Stores BOTH raw description and Jinja-rendered version for analytics
# Note: Pass copies and captured state since this runs in background thread
self._store_card_pattern(
card_id=card_id,
description=description,
title=title,
structure_dsl=structure_dsl,
card=dict(card) if card else {}, # Copy to avoid mutation issues
description_rendered=self._description_rendered,
jinja_applied=self._jinja_applied,
)
# Add feedback section AFTER storing content pattern
# The feedback UI is stored SEPARATELY with pattern_type="feedback_ui"
if ENABLE_FEEDBACK_BUTTONS and card and not self._has_feedback(card):
feedback = self._create_feedback_section(card_id)
# Store the feedback UI pattern separately (pattern_type="feedback_ui")
# Note: Pass copy since this runs in background thread
self._store_feedback_ui_pattern(card_id, dict(feedback) if feedback else {})
# Add a divider before feedback to visually separate content from feedback UI
divider_section = {"widgets": [{"divider": {}}]}
if "sections" in card:
card["sections"].append(divider_section)
card["sections"].append(feedback)
else:
card["sections"] = [divider_section, feedback]
# Clean metadata before returning (Google Chat rejects underscore-prefixed keys)
return self._clean_card_metadata(card)
def initialize(self):
"""Initialize the builder (v1 compatibility)."""
# V2 initializes lazily, this is a no-op for compatibility
pass
# =============================================================================
# DSL SUGGESTION - Suggest DSL based on card_params
# =============================================================================
def suggest_dsl_for_params(
card_params: Dict[str, Any], symbols: Dict[str, str]
) -> Optional[str]:
"""Suggest DSL structure based on provided card_params.
Delegates to gchat.card_builder.dsl.suggest_dsl_for_params().
Args:
card_params: Dictionary with parameters like text, buttons, image_url, etc.
symbols: Symbol mapping from ModuleWrapper (e.g., wrapper.symbol_mapping)
Returns:
Suggested DSL string using symbols from the mapping, or None if no suggestion
"""
from gchat.card_builder.dsl import suggest_dsl_for_params as _suggest_dsl
return _suggest_dsl(card_params, symbols)
# =============================================================================
# SINGLETON AND CONVENIENCE FUNCTIONS
# =============================================================================
_builder: Optional[SmartCardBuilderV2] = None
def get_smart_card_builder() -> SmartCardBuilderV2:
"""Get the global SmartCardBuilderV2 instance (v1 compatible)."""
global _builder
if _builder is None:
_builder = SmartCardBuilderV2()
return _builder
def reset_builder():
"""Reset the singleton builder."""
global _builder
_builder = None
def build_card(
description: str,
title: Optional[str] = None,
subtitle: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Build a card using SmartCardBuilderV2."""
builder = get_smart_card_builder()
return builder.build(
description=description, title=title, subtitle=subtitle, **kwargs
)
# Backwards compatibility alias
SmartCardBuilder = SmartCardBuilderV2