dataface-playground 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. dataface_playground/__init__.py +1 -0
  2. dataface_playground/ai_service.py +535 -0
  3. dataface_playground/ai_service_streaming.py +341 -0
  4. dataface_playground/ai_tools.py +45 -0
  5. dataface_playground/app.py +182 -0
  6. dataface_playground/cli.py +184 -0
  7. dataface_playground/error_display.py +41 -0
  8. dataface_playground/examples.py +288 -0
  9. dataface_playground/examples_data/examples/_shared_queries.yml +40 -0
  10. dataface_playground/examples_data/examples/_sources.yaml +55 -0
  11. dataface_playground/examples_data/examples/assets/data/area-stack-modes/three_series.csv +61 -0
  12. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_area_multi_2.csv +41 -0
  13. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_bar_category.csv +9 -0
  14. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_column_line_combo.csv +10 -0
  15. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_column_stacked.csv +49 -0
  16. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_donut_2.csv +3 -0
  17. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_donut_6.csv +7 -0
  18. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_line_multi_2.csv +157 -0
  19. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_line_multi_6.csv +469 -0
  20. dataface_playground/examples_data/examples/assets/data/chart-lab-import-review/lab_vertical_bar.csv +10 -0
  21. dataface_playground/examples_data/examples/assets/data/ecommerce_orders.csv +153 -0
  22. dataface_playground/examples_data/examples/assets/data/employees.csv +11 -0
  23. dataface_playground/examples_data/examples/assets/data/inventory.csv +9 -0
  24. dataface_playground/examples_data/examples/assets/data/kpi-specimens/quarterly_kpis.csv +2 -0
  25. dataface_playground/examples_data/examples/assets/data/table-specimens/company_revenue.csv +9 -0
  26. dataface_playground/examples_data/examples/assets/data/table-specimens/company_revenue_full_summary.csv +10 -0
  27. dataface_playground/examples_data/examples/assets/data/table-specimens/company_revenue_with_avg.csv +10 -0
  28. dataface_playground/examples_data/examples/assets/data/table-specimens/company_revenue_with_total.csv +10 -0
  29. dataface_playground/examples_data/examples/assets/data/table-specimens/country_gdp.csv +8 -0
  30. dataface_playground/examples_data/examples/assets/data/table-specimens/country_gdp_with_avg_col.csv +8 -0
  31. dataface_playground/examples_data/examples/assets/data/table-specimens/events_dates.csv +9 -0
  32. dataface_playground/examples_data/examples/assets/data/table-specimens/events_dates_full.csv +9 -0
  33. dataface_playground/examples_data/examples/assets/data/table-specimens/growth_rates.csv +8 -0
  34. dataface_playground/examples_data/examples/assets/data/table-specimens/growth_rates_pct.csv +8 -0
  35. dataface_playground/examples_data/examples/assets/data/table-specimens/museum_visitors.csv +11 -0
  36. dataface_playground/examples_data/examples/assets/data/table-specimens/sales_pipeline.csv +11 -0
  37. dataface_playground/examples_data/examples/assets/data/table-specimens/state_demographics.csv +31 -0
  38. dataface_playground/examples_data/examples/assets/data/table-specimens/us_states_top8.csv +9 -0
  39. dataface_playground/examples_data/examples/assets/data/us_states.csv +51 -0
  40. dataface_playground/examples_data/examples/assets/data/world_countries.csv +55 -0
  41. dataface_playground/examples_data/examples/assets/images/dbt-logo.svg +19 -0
  42. dataface_playground/examples_data/examples/assets/images/fivetran-logo.svg +22 -0
  43. dataface_playground/examples_data/examples/dataface.yml +35 -0
  44. dataface_playground/examples_data/examples/examples.duckdb +0 -0
  45. dataface_playground/examples_data/examples/playground/faces/charts/_settings.json +4 -0
  46. dataface_playground/examples_data/examples/playground/faces/charts/all-chart-types.yml +145 -0
  47. dataface_playground/examples_data/examples/playground/faces/charts/anatomy-of-a-signature.yml +585 -0
  48. dataface_playground/examples_data/examples/playground/faces/charts/axis-label-stress-test.yml +228 -0
  49. dataface_playground/examples_data/examples/playground/faces/charts/callout-tones.yml +70 -0
  50. dataface_playground/examples_data/examples/playground/faces/charts/nyc-neighborhoods-map.yml +56 -0
  51. dataface_playground/examples_data/examples/playground/faces/charts/sf-neighborhoods-map.yml +69 -0
  52. dataface_playground/examples_data/examples/playground/faces/charts/store-locations-map.yml +48 -0
  53. dataface_playground/examples_data/examples/playground/faces/charts/us-states-map.yml +27 -0
  54. dataface_playground/examples_data/examples/playground/faces/charts/width-compaction.yml +172 -0
  55. dataface_playground/examples_data/examples/playground/faces/charts/world-map.yml +41 -0
  56. dataface_playground/examples_data/examples/playground/faces/general/_settings.json +4 -0
  57. dataface_playground/examples_data/examples/playground/faces/general/company-overview-tight.yml +467 -0
  58. dataface_playground/examples_data/examples/playground/faces/general/company-overview.yml +401 -0
  59. dataface_playground/examples_data/examples/playground/faces/general/dundersign-commercial-finance-default.yml +517 -0
  60. dataface_playground/examples_data/examples/playground/faces/general/dundersign-commercial-finance.yml +530 -0
  61. dataface_playground/examples_data/examples/playground/faces/general/dundersign-support-operations.yml +544 -0
  62. dataface_playground/examples_data/examples/playground/faces/general/products-overview.yml +57 -0
  63. dataface_playground/examples_data/examples/playground/faces/general/sales-overview.yml +58 -0
  64. dataface_playground/examples_data/examples/playground/faces/general/simple-example.yml +24 -0
  65. dataface_playground/examples_data/examples/playground/faces/general/users-overview.yml +41 -0
  66. dataface_playground/examples_data/examples/playground/faces/reference/_settings.json +4 -0
  67. dataface_playground/examples_data/examples/playground/faces/reference/area-stack-modes.yml +115 -0
  68. dataface_playground/examples_data/examples/playground/faces/reference/chart-design-lab.yml +151 -0
  69. dataface_playground/examples_data/examples/playground/faces/reference/chart-lab-import-review.yml +289 -0
  70. dataface_playground/examples_data/examples/playground/faces/reference/color-palettes.yml +1187 -0
  71. dataface_playground/examples_data/examples/playground/faces/reference/content-test.yml +34 -0
  72. dataface_playground/examples_data/examples/playground/faces/reference/dark-theme-table-example.yml +77 -0
  73. dataface_playground/examples_data/examples/playground/faces/reference/dataface-guide.yml +217 -0
  74. dataface_playground/examples_data/examples/playground/faces/reference/date-format-explorer.yml +456 -0
  75. dataface_playground/examples_data/examples/playground/faces/reference/editorial-stress-test.yml +253 -0
  76. dataface_playground/examples_data/examples/playground/faces/reference/hello_world.yml +35 -0
  77. dataface_playground/examples_data/examples/playground/faces/reference/link-examples.yml +77 -0
  78. dataface_playground/examples_data/examples/playground/faces/reference/markdown-guide.yml +401 -0
  79. dataface_playground/examples_data/examples/playground/faces/reference/nested-layouts.yml +244 -0
  80. dataface_playground/examples_data/examples/playground/faces/reference/overlap-tilt-explorer.yml +254 -0
  81. dataface_playground/examples_data/examples/playground/faces/reference/palette-reference.yml +295 -0
  82. dataface_playground/examples_data/examples/playground/faces/reference/quick-guide.yml +604 -0
  83. dataface_playground/examples_data/examples/playground/faces/reference/series-label-placement.yml +179 -0
  84. dataface_playground/examples_data/examples/playground/faces/reference/source_example.yml +61 -0
  85. dataface_playground/examples_data/examples/playground/faces/reference/spark-charts-demo.yml +46 -0
  86. dataface_playground/examples_data/examples/playground/faces/reference/stacked-bar-stress-test.yml +71 -0
  87. dataface_playground/examples_data/examples/playground/faces/reference/styling.yml +390 -0
  88. dataface_playground/examples_data/examples/playground/faces/reference/time-unit-explorer.yml +414 -0
  89. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/_settings.json +4 -0
  90. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/data-table-attached.yml +221 -0
  91. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/kpi-specimen-foundations.yml +254 -0
  92. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/pagination-autoshrink-repro.yml +65 -0
  93. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/paginator-lab.yml +132 -0
  94. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/table-emphasis.yml +484 -0
  95. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/table-foundations.yml +775 -0
  96. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/table-spark-charts.yml +87 -0
  97. dataface_playground/examples_data/examples/playground/faces/tables-and-kpis/table-style-variants.yml +141 -0
  98. dataface_playground/examples_data/examples/playground/faces/variables/_settings.json +4 -0
  99. dataface_playground/examples_data/examples/playground/faces/variables/game-of-life.yml +635 -0
  100. dataface_playground/examples_data/examples/playground/faces/variables/imported-board-nested.yml +135 -0
  101. dataface_playground/examples_data/examples/playground/faces/variables/interactive-nested.yml +118 -0
  102. dataface_playground/examples_data/examples/playground/faces/variables/interactive-test.yml +52 -0
  103. dataface_playground/examples_data/examples/playground/faces/variables/interactive-variables.yml +137 -0
  104. dataface_playground/examples_data/examples/playground/faces/variables/interactive.yml +126 -0
  105. dataface_playground/examples_data/examples/playground/faces/variables/tic-tac-toe.yml +386 -0
  106. dataface_playground/prompts/dashboard_design.md +76 -0
  107. dataface_playground/prompts/database_exploration.md +86 -0
  108. dataface_playground/prompts/report_generation.md +74 -0
  109. dataface_playground/prompts/yaml_generation.md +559 -0
  110. dataface_playground/routes.py +1145 -0
  111. dataface_playground/share_cache.py +16 -0
  112. dataface_playground/source_resolver_selector.py +33 -0
  113. dataface_playground/static/ai-sidebar.js +702 -0
  114. dataface_playground/static/bootstrap.js +122 -0
  115. dataface_playground/static/celebration.js +173 -0
  116. dataface_playground/static/code-preview.js +28 -0
  117. dataface_playground/static/dataface-mode.js +166 -0
  118. dataface_playground/static/dom.js +17 -0
  119. dataface_playground/static/download.js +123 -0
  120. dataface_playground/static/editor.js +66 -0
  121. dataface_playground/static/examples-gallery.js +564 -0
  122. dataface_playground/static/favicon.svg +4 -0
  123. dataface_playground/static/index.html +207 -0
  124. dataface_playground/static/pane-resize.js +252 -0
  125. dataface_playground/static/playground.css +1970 -0
  126. dataface_playground/static/preview.js +573 -0
  127. dataface_playground/static/search.js +152 -0
  128. dataface_playground/static/share-link.js +124 -0
  129. dataface_playground/static/state.js +84 -0
  130. dataface_playground/static/websocket.js +39 -0
  131. dataface_playground/thumbnails.py +10 -0
  132. dataface_playground/websocket.py +63 -0
  133. dataface_playground-0.0.1.dist-info/METADATA +78 -0
  134. dataface_playground-0.0.1.dist-info/RECORD +137 -0
  135. dataface_playground-0.0.1.dist-info/WHEEL +4 -0
  136. dataface_playground-0.0.1.dist-info/entry_points.txt +2 -0
  137. dataface_playground-0.0.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1 @@
1
+ """Dataface interactive playground — OSS dashboard editor package."""
@@ -0,0 +1,535 @@
1
+ """AI service for playground chat functionality.
2
+
3
+ This module provides OpenAI integration for generating dashboards, reports,
4
+ and providing database exploration assistance. Tool calls are dispatched
5
+ through the canonical Dataface AI tool surface.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from dataface.agent_api.docs import read_full_text as _read_dataface_syntax
14
+ from dataface.ai.context import DatafaceAIContext
15
+ from dataface.ai.generate_sql import get_sql_generation_guidance
16
+ from dataface.ai.prompts import build_context_section, load_prompt, load_shared_prompt
17
+ from dataface.ai.tool_schemas import to_openai_tool
18
+ from dataface.ai.tools import dispatch_tool_call
19
+ from dataface.ai.yaml_utils import extract_yaml
20
+ from dataface_playground.ai_tools import PLAYGROUND_TOOLS, _handle_render_dashboard
21
+ from dataface_playground.error_display import error_summary as format_playground_error
22
+
23
+
24
+ class AIService:
25
+ """Service for interacting with OpenAI API."""
26
+
27
+ _TOOL_INSTRUCTIONS = """
28
+
29
+ ## IMPORTANT: Rendering and Testing
30
+
31
+ You have access to tools for working with dashboards:
32
+
33
+ 1. **ALWAYS render** your generated YAML using `render_dashboard` before returning it — this checks structure, executes queries, and verifies render output in one step
34
+ 2. **Use `execute_query`** to run SQL directly for data exploration (e.g. discovering tables, checking column names)
35
+ 3. **Use `schema`** to browse the data hierarchy: sources, schemas, tables, and columns
36
+ 4. **Fix any errors** reported by `render_dashboard` and render again until it succeeds
37
+ 5. **Only return YAML** that renders successfully
38
+ 6. If `schema(table=...)` says a table has **no cached profile**, that means the table may still exist live. Use the returned columns and/or `execute_query` to confirm instead of claiming the table is missing.
39
+
40
+ If rendering fails, analyze the errors (including tips), fix them, and render again. Each render attempt is visible to the user in the playground editor, so keep iterating with complete YAML.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str | None = None,
46
+ ai_context: DatafaceAIContext | None = None,
47
+ ) -> None:
48
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY")
49
+ self._client: Any = None
50
+ self.prompts_dir = Path(__file__).parent / "prompts"
51
+ self._ai_context = ai_context
52
+
53
+ @property
54
+ def client(self) -> Any:
55
+ """Lazy-load OpenAI client."""
56
+ if self._client is None and self.api_key:
57
+ from openai import OpenAI
58
+
59
+ self._client = OpenAI(api_key=self.api_key)
60
+ return self._client
61
+
62
+ @property
63
+ def is_available(self) -> bool:
64
+ """Lazily initializes the client if API key is now available in environment."""
65
+ if self._client is None:
66
+ api_key = self.api_key or os.getenv("OPENAI_API_KEY")
67
+ if api_key:
68
+ self.api_key = api_key
69
+ self._client = None # Reset so client property re-creates
70
+ return self.client is not None
71
+
72
+ def _load_prompt(self, prompt_name: str) -> str:
73
+ """Load a prompt template from markdown file.
74
+
75
+ Args:
76
+ prompt_name: Name of prompt file (without .md extension)
77
+
78
+ Returns:
79
+ Prompt content as string
80
+ """
81
+ # Use core utility for prompt loading
82
+ return load_prompt(prompt_name, self.prompts_dir)
83
+
84
+ def _build_system_prompt(
85
+ self,
86
+ prompt_type: str,
87
+ database_context: str | None = None,
88
+ yaml_context: str | None = None,
89
+ chart_context: str | None = None,
90
+ ) -> str:
91
+ """Build system prompt with context.
92
+
93
+ Args:
94
+ prompt_type: Type of prompt (database_exploration, yaml_generation, etc.)
95
+ database_context: Optional database schema information
96
+ yaml_context: Optional selected YAML code
97
+ chart_context: Optional chart information
98
+
99
+ Returns:
100
+ Complete system prompt
101
+ """
102
+ base_prompt = self._load_prompt(prompt_type)
103
+
104
+ # Prepend shared design guide for dashboard/report prompts.
105
+ # Shared guides under dataface.ai.skills carry universal design
106
+ # principles; the app-specific prompts add tool instructions.
107
+ shared_guide_map = {
108
+ "dashboard_design": "dashboard-design",
109
+ "report_generation": "report-design",
110
+ }
111
+ if prompt_type in shared_guide_map:
112
+ shared = load_shared_prompt(shared_guide_map[prompt_type])
113
+ if shared:
114
+ base_prompt = shared + "\n\n---\n\n" + base_prompt
115
+
116
+ if prompt_type in ("yaml_generation", "dashboard_design", "report_generation"):
117
+ base_prompt += "\n\n" + get_sql_generation_guidance()
118
+
119
+ schema_section = ""
120
+ if prompt_type in ("yaml_generation", "dashboard_design", "report_generation"):
121
+ schema_section = f"\n\n{_read_dataface_syntax()}\n\n"
122
+
123
+ # Use core utility for building context section
124
+ context_section = build_context_section(
125
+ database_context=database_context,
126
+ yaml_context=yaml_context,
127
+ chart_context=chart_context,
128
+ )
129
+
130
+ # Build final prompt: context first, then base prompt, then schema
131
+ if context_section:
132
+ return context_section + "\n\n" + base_prompt + schema_section
133
+
134
+ return base_prompt + schema_section
135
+
136
+ def _get_tools(self) -> list[dict[str, Any]]:
137
+ """Get playground tool schemas in OpenAI function-calling format.
138
+
139
+ Returns:
140
+ List of tool definitions for OpenAI function calling
141
+ """
142
+ return [to_openai_tool(tool) for tool in PLAYGROUND_TOOLS]
143
+
144
+ def _dispatch_tool(
145
+ self, function_name: str, function_args: dict[str, Any]
146
+ ) -> dict[str, Any]:
147
+ """Dispatch a tool call via the shared canonical tool surface.
148
+
149
+ When the AI omits a ``source`` on query/schema calls, injects the
150
+ context's ``default_source`` so the tool hits the same database the
151
+ schema preview discovered and the rendered YAML will use.
152
+ """
153
+ ctx = self._ai_context
154
+ if ctx is None:
155
+ from dataface_playground.routes import get_playground_ai_context
156
+
157
+ ctx = get_playground_ai_context()
158
+
159
+ if ctx.default_source and function_name in (
160
+ "execute_query",
161
+ "schema",
162
+ ):
163
+ function_args.setdefault("source", ctx.default_source)
164
+
165
+ if function_name == "render_dashboard":
166
+ return _handle_render_dashboard(function_args, ctx)
167
+
168
+ return dispatch_tool_call(function_name, function_args, context=ctx)
169
+
170
+ def _tool_instructions(self) -> str:
171
+ """Return the shared tool instructions for playground prompts."""
172
+ return self._TOOL_INSTRUCTIONS
173
+
174
+ def chat(
175
+ self,
176
+ user_message: str,
177
+ prompt_type: str = "yaml_generation",
178
+ database_context: str | None = None,
179
+ yaml_context: str | None = None,
180
+ chart_context: str | None = None,
181
+ conversation_history: list[dict[str, Any]] | None = None,
182
+ enable_validation: bool = True,
183
+ ) -> dict[str, Any]:
184
+ """Send a chat message to OpenAI with optional render tools.
185
+
186
+ Args:
187
+ user_message: User's message
188
+ prompt_type: Type of prompt to use (database_exploration, yaml_generation, report_generation, dashboard_design)
189
+ database_context: Optional database schema information
190
+ yaml_context: Optional selected YAML code
191
+ chart_context: Optional chart information
192
+ conversation_history: Optional list of previous messages [{role: str, content: str}]
193
+ enable_validation: If True, enable function calling for render checks (default: True)
194
+
195
+ Returns:
196
+ Dict with 'response' (str) and 'yaml' (optional str if YAML was generated)
197
+
198
+ Raises:
199
+ ValueError: If AI service is not available
200
+ """
201
+ if not self.is_available:
202
+ raise ValueError(
203
+ "OpenAI API key not configured. Set OPENAI_API_KEY environment variable."
204
+ )
205
+
206
+ # Build system prompt
207
+ system_prompt = self._build_system_prompt(
208
+ prompt_type=prompt_type,
209
+ database_context=database_context,
210
+ yaml_context=yaml_context,
211
+ chart_context=chart_context,
212
+ )
213
+
214
+ # Add instructions about playground tools
215
+ if enable_validation and prompt_type in (
216
+ "yaml_generation",
217
+ "dashboard_design",
218
+ "report_generation",
219
+ ):
220
+ system_prompt += self._tool_instructions()
221
+
222
+ # Build messages
223
+ messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
224
+
225
+ # Add conversation history
226
+ if conversation_history:
227
+ messages.extend(conversation_history)
228
+
229
+ # Add current user message
230
+ messages.append({"role": "user", "content": user_message})
231
+
232
+ # Call OpenAI API with function calling if render checks are enabled
233
+ max_iterations = 10 # Increased to allow more iteration for complex dashboards
234
+ allow_return_with_errors_after = (
235
+ 7 # After this many iterations, allow returning with errors
236
+ )
237
+ iteration = 0
238
+ last_yaml_content = None # Track last generated YAML
239
+
240
+ try:
241
+ while iteration < max_iterations:
242
+ iteration += 1
243
+
244
+ # After threshold iterations, tell agent it can return with explanation if stuck
245
+ if iteration == allow_return_with_errors_after:
246
+ messages.append(
247
+ {
248
+ "role": "user",
249
+ "content": "You've tried several times to fix the errors. If you're hitting a real issue that you cannot fix (like a configuration problem, missing database connection, or something outside your control), you can return the YAML you've generated along with an explanation of what the issue is. The user will be able to see what you've created and understand the problem. However, if you think you can fix it with one more attempt, please try again.",
250
+ }
251
+ )
252
+
253
+ # Prepare API call
254
+ # Try GPT-5 Thinking (gpt-5.1) first, fallback to gpt-4o if not available
255
+ # GPT-5 supports advanced reasoning and function calling
256
+ model = "gpt-5.1"
257
+
258
+ api_kwargs: dict[str, Any] = {
259
+ "model": model,
260
+ "messages": messages,
261
+ "temperature": 0.7,
262
+ "max_tokens": 4000,
263
+ "stream": False, # Set to True for streaming (handled separately)
264
+ }
265
+
266
+ # Add reasoning parameter if supported (GPT-5 models)
267
+ # This may not be supported in all SDK versions, so we'll handle errors gracefully
268
+ api_kwargs["reasoning"] = {
269
+ "summary": "auto", # Enable reasoning summaries for status indicators
270
+ "effort": "medium", # Balance between quality and latency
271
+ }
272
+
273
+ # Add tools if render checks are enabled
274
+ if enable_validation and prompt_type in (
275
+ "yaml_generation",
276
+ "dashboard_design",
277
+ "report_generation",
278
+ ):
279
+ api_kwargs["tools"] = self._get_tools()
280
+ api_kwargs["tool_choice"] = "auto"
281
+
282
+ # Try API call with reasoning, fallback without if not supported
283
+ try:
284
+ response = self.client.chat.completions.create(**api_kwargs)
285
+ except (TypeError, ValueError) as e:
286
+ # If reasoning parameter is not supported, retry without it
287
+ error_str = str(e).lower()
288
+ if "reasoning" in error_str or "unexpected keyword" in error_str:
289
+ api_kwargs.pop("reasoning", None)
290
+ # Also try fallback model if gpt-5.1 doesn't exist
291
+ if model == "gpt-5.1":
292
+ api_kwargs["model"] = "gpt-4o"
293
+ response = self.client.chat.completions.create(**api_kwargs)
294
+ else:
295
+ raise
296
+
297
+ assistant_message = response.choices[0].message
298
+
299
+ # Check if function calling was used
300
+ if assistant_message.tool_calls:
301
+ # Add assistant message to conversation
302
+ # Convert tool_calls to dict format
303
+ tool_calls_dict = [
304
+ {
305
+ "id": tc.id,
306
+ "type": tc.type,
307
+ "function": {
308
+ "name": tc.function.name,
309
+ "arguments": tc.function.arguments,
310
+ },
311
+ }
312
+ for tc in assistant_message.tool_calls
313
+ ]
314
+ messages.append(
315
+ {
316
+ "role": "assistant",
317
+ "content": assistant_message.content,
318
+ "tool_calls": tool_calls_dict,
319
+ }
320
+ )
321
+
322
+ # Execute tool calls
323
+ for tool_call in assistant_message.tool_calls:
324
+ # Handle both object and dict access
325
+ if hasattr(tool_call, "function"):
326
+ function_name = tool_call.function.name
327
+ function_args_str = tool_call.function.arguments
328
+ tool_call_id = tool_call.id
329
+ else:
330
+ function_name = tool_call["function"]["name"]
331
+ function_args_str = tool_call["function"]["arguments"]
332
+ tool_call_id = tool_call["id"]
333
+
334
+ function_args = (
335
+ json.loads(function_args_str) if function_args_str else {}
336
+ )
337
+
338
+ result = self._dispatch_tool(function_name, function_args)
339
+
340
+ # Truncate large data fields for the AI context window
341
+ if "data" in result and isinstance(result["data"], list):
342
+ result = {
343
+ **result,
344
+ "data": result["data"][:20],
345
+ }
346
+
347
+ messages.append(
348
+ {
349
+ "role": "tool",
350
+ "tool_call_id": tool_call_id,
351
+ "name": function_name,
352
+ "content": json.dumps(result, default=str),
353
+ }
354
+ )
355
+
356
+ # Continue loop to get AI's response after function calls
357
+ continue
358
+
359
+ # No function calls - we have the final response
360
+ assistant_content = assistant_message.content or ""
361
+
362
+ # Try to extract YAML from response
363
+ yaml_content = self._extract_yaml(assistant_content)
364
+
365
+ # Track the last YAML content we've seen
366
+ if yaml_content:
367
+ last_yaml_content = yaml_content
368
+
369
+ # If we have YAML but render checks were enabled, do final checks
370
+ if (
371
+ yaml_content
372
+ and enable_validation
373
+ and prompt_type
374
+ in (
375
+ "yaml_generation",
376
+ "dashboard_design",
377
+ "report_generation",
378
+ )
379
+ ):
380
+ render_result = self._dispatch_tool(
381
+ "render_dashboard",
382
+ {"yaml_content": yaml_content},
383
+ )
384
+
385
+ if not render_result.get("success", False):
386
+ # After threshold, allow agent to return with explanation if it wants
387
+ if iteration >= allow_return_with_errors_after:
388
+ if any(
389
+ phrase in assistant_content.lower()
390
+ for phrase in [
391
+ "cannot fix",
392
+ "unable to",
393
+ "configuration",
394
+ "missing",
395
+ "outside my control",
396
+ "requires",
397
+ "need to",
398
+ "issue is",
399
+ ]
400
+ ):
401
+ return {
402
+ "response": assistant_content,
403
+ "yaml": yaml_content,
404
+ }
405
+
406
+ # Continue iterating — feed errors back to AI
407
+ render_error_summary = render_result.get("error_summary", "")
408
+ errors = render_result.get("errors", [])
409
+ tips = render_result.get("tips", [])
410
+
411
+ error_feedback = (
412
+ "Dashboard rendering failed. Errors:\n"
413
+ f"{render_error_summary}"
414
+ )
415
+ if errors:
416
+ error_feedback += "\n\nDetailed errors:\n" + "\n".join(
417
+ format_playground_error(e) for e in errors[:5]
418
+ )
419
+ if tips:
420
+ error_feedback += "\n\nTips:\n" + "\n".join(tips)
421
+ error_feedback += "\n\nPlease fix these errors and regenerate the complete YAML. Use `execute_query` to discover table names if needed. Do not ask questions - fix the errors and return complete, valid YAML."
422
+
423
+ messages.append({"role": "user", "content": error_feedback})
424
+ continue
425
+
426
+ # Render checks passed (or were disabled) - return the response
427
+ return {
428
+ "response": assistant_content,
429
+ "yaml": yaml_content,
430
+ }
431
+
432
+ # If we've done too many iterations, return the last YAML we saw with explanation
433
+ # Ask the agent to explain what happened
434
+ if last_yaml_content:
435
+ # One final attempt to get an explanation from the agent
436
+ messages.append(
437
+ {
438
+ "role": "user",
439
+ "content": "You've reached the maximum number of render attempts. Please return the YAML you've generated along with an explanation of what issues remain and why they couldn't be fixed. The user needs to see what you've created.",
440
+ }
441
+ )
442
+
443
+ try:
444
+ # Get final response with explanation
445
+ api_kwargs = {
446
+ "model": "gpt-5.1",
447
+ "messages": messages,
448
+ "temperature": 0.7,
449
+ "max_tokens": 4000,
450
+ }
451
+ # Try adding reasoning if supported
452
+ api_kwargs["reasoning"] = {
453
+ "summary": "auto",
454
+ "effort": "medium",
455
+ }
456
+
457
+ try:
458
+ response = self.client.chat.completions.create(**api_kwargs)
459
+ except (TypeError, ValueError) as e:
460
+ # If reasoning parameter is not supported, retry without it
461
+ error_str = str(e).lower()
462
+ if (
463
+ "reasoning" in error_str
464
+ or "unexpected keyword" in error_str
465
+ ):
466
+ api_kwargs.pop("reasoning", None)
467
+ # Also try fallback model if gpt-5.1 doesn't exist
468
+ if api_kwargs.get("model") == "gpt-5.1":
469
+ api_kwargs["model"] = "gpt-4o"
470
+ response = self.client.chat.completions.create(**api_kwargs)
471
+ else:
472
+ raise
473
+ assistant_content = response.choices[0].message.content or ""
474
+
475
+ # Extract YAML if present, otherwise use last known YAML
476
+ final_yaml = (
477
+ self._extract_yaml(assistant_content) or last_yaml_content
478
+ )
479
+
480
+ return {
481
+ "response": assistant_content
482
+ or "Reached maximum render iterations. Here's the YAML that was generated. Please review it manually.",
483
+ "yaml": final_yaml,
484
+ }
485
+ except Exception: # noqa: BLE001
486
+ # If final call fails, still return what we have
487
+ return {
488
+ "response": "Reached maximum render iterations. Here's the YAML that was generated. Please review it manually.",
489
+ "yaml": last_yaml_content,
490
+ }
491
+ else:
492
+ # No YAML was ever generated
493
+ return {
494
+ "response": "Reached maximum render iterations but no YAML was generated. Please try again with a different request.",
495
+ "yaml": None,
496
+ }
497
+
498
+ except Exception as e: # noqa: BLE001
499
+ return {
500
+ "response": f"Error: {str(e)}",
501
+ "yaml": None,
502
+ }
503
+
504
+ def _extract_yaml(self, text: str) -> str | None:
505
+ """Extract YAML code block from text.
506
+
507
+ Args:
508
+ text: Text that may contain YAML code blocks
509
+
510
+ Returns:
511
+ Extracted YAML content or None
512
+ """
513
+ # Use core utility for YAML extraction
514
+ return extract_yaml(text)
515
+
516
+
517
+ # Global instance
518
+ _ai_service: AIService | None = None
519
+
520
+
521
+ def get_ai_service() -> AIService:
522
+ """Get global AI service instance."""
523
+ global _ai_service
524
+ if _ai_service is None:
525
+ _ai_service = AIService()
526
+ return _ai_service
527
+
528
+
529
+ def reset_ai_service() -> None:
530
+ """Reset the global AI service instance.
531
+
532
+ This is useful when environment variables change (e.g., after loading .env file).
533
+ """
534
+ global _ai_service
535
+ _ai_service = None