mikeboone Claude Sonnet 4.6 commited on
Commit
981f101
ยท
1 Parent(s): 4acca4e

feat: run history tab, semantics batching, tag v2 fallback, fixes

Browse files

- Add Run History admin tab with 10/50/100/All selector + email filter
- Log company/use_case in session meta for run tracking
- Fix Custom vertical: also disable Function dropdown (like Line)
- model_semantic_updater: batch columns in groups of 25, add 60s timeout
- thoughtspot_deployer: fix empty tag log, add v2 tag API fallback for models
- Fix [OK] 0 columns enriched โ†’ [WARN] when semantics returns nothing
- Add 5-min hard timeout to MCP liveboard creation (asyncio.wait_for)
- Add pipeline summary (model + liveboard URLs) before pipeline complete
- Existing model path: use create_liveboard_from_model_mcp with real ts_client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. chat_interface.py +152 -17
  2. model_semantic_updater.py +27 -18
  3. thoughtspot_deployer.py +47 -10
chat_interface.py CHANGED
@@ -1744,6 +1744,8 @@ To change settings, use:
1744
  generic_context: Additional context provided by user for generic use cases
1745
  """
1746
  _slog = self._session_logger
 
 
1747
  _t = _slog.log_start("research") if _slog else None
1748
 
1749
  print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
@@ -3574,8 +3576,9 @@ Tables: Created and populated
3574
  self.log_feedback(f"Using existing model: {existing_model_guid}")
3575
 
3576
  try:
3577
- from liveboard_creator import create_liveboard_from_model
3578
-
 
3579
  # Get ThoughtSpot settings
3580
  ts_url = get_admin_setting('THOUGHTSPOT_URL')
3581
  ts_user = self._get_effective_user_email()
@@ -3583,12 +3586,10 @@ Tables: Created and populated
3583
  if not ts_secret:
3584
  raise ValueError("ThoughtSpot trusted auth key not set. Select a TS environment from the dropdown.")
3585
 
3586
- liveboard_method = 'HYBRID' # Only HYBRID method is supported
3587
-
3588
  # Clean company name for display (strip .com, .org, etc)
3589
  clean_company = company.split('.')[0].title() if '.' in company else company
3590
  liveboard_name = self.settings.get('liveboard_name', '') or f"{clean_company} - {use_case}"
3591
-
3592
  # Get company data for liveboard
3593
  company_data = {
3594
  'name': clean_company,
@@ -3597,18 +3598,27 @@ Tables: Created and populated
3597
  'primary_color': getattr(self.demo_builder, 'primary_color', '#3498db'),
3598
  'secondary_color': getattr(self.demo_builder, 'secondary_color', '#2c3e50')
3599
  }
3600
-
3601
- yield f"**Creating Liveboard from Existing Model**\n\nMethod: {liveboard_method}\nModel: `{existing_model_guid}`\n\n"
3602
-
3603
- # Create liveboard
3604
- liveboard_result = create_liveboard_from_model(
 
 
 
 
 
 
 
 
3605
  model_id=existing_model_guid,
3606
- model_name="Existing Model", # We don't have the name
3607
- use_case=use_case,
3608
  company_data=company_data,
 
 
3609
  liveboard_name=liveboard_name,
3610
- method=liveboard_method,
3611
- num_visualizations=8
3612
  )
3613
 
3614
  if liveboard_result.get('success'):
@@ -4775,6 +4785,129 @@ def create_chat_interface():
4775
  with gr.Tab("๐Ÿงฉ Matrix"):
4776
  matrix_components = create_matrix_tab(interface)
4777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4778
  # Check admin status and toggle admin-only settings visibility
4779
  def check_admin_visibility(request: gr.Request):
4780
  """Check if logged-in user is admin and toggle settings visibility."""
@@ -5168,23 +5301,25 @@ def create_chat_tab(chat_controller_state, settings, current_stage, current_mode
5168
  msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
5169
  send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
5170
 
5171
- # App tab: vertical โ†’ line cascade
5172
  def update_line_on_vertical(vertical):
5173
  lines = VERTICAL_LINES.get(vertical, [])
5174
  if vertical == "Custom" or not lines:
5175
  return (
5176
  gr.Dropdown(choices=[], value=None, interactive=False),
5177
- gr.Textbox(label="Context *", placeholder="Describe your use case...", interactive=True),
 
5178
  )
5179
  return (
5180
  gr.Dropdown(choices=lines, value=lines[0], interactive=True),
 
5181
  gr.Textbox(label="Context", placeholder="Any extra context for the demo...", interactive=True),
5182
  )
5183
 
5184
  vertical_dd.change(
5185
  fn=update_line_on_vertical,
5186
  inputs=[vertical_dd],
5187
- outputs=[line_dd, additional_info_input]
5188
  )
5189
 
5190
  # Defined tab: GO button handler
 
1744
  generic_context: Additional context provided by user for generic use cases
1745
  """
1746
  _slog = self._session_logger
1747
+ if _slog:
1748
+ _slog.log("run", "run started", company=company or '', use_case=use_case or '')
1749
  _t = _slog.log_start("research") if _slog else None
1750
 
1751
  print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
 
3576
  self.log_feedback(f"Using existing model: {existing_model_guid}")
3577
 
3578
  try:
3579
+ from liveboard_creator import create_liveboard_from_model_mcp
3580
+ from thoughtspot_deployer import ThoughtSpotDeployer
3581
+
3582
  # Get ThoughtSpot settings
3583
  ts_url = get_admin_setting('THOUGHTSPOT_URL')
3584
  ts_user = self._get_effective_user_email()
 
3586
  if not ts_secret:
3587
  raise ValueError("ThoughtSpot trusted auth key not set. Select a TS environment from the dropdown.")
3588
 
 
 
3589
  # Clean company name for display (strip .com, .org, etc)
3590
  clean_company = company.split('.')[0].title() if '.' in company else company
3591
  liveboard_name = self.settings.get('liveboard_name', '') or f"{clean_company} - {use_case}"
3592
+
3593
  # Get company data for liveboard
3594
  company_data = {
3595
  'name': clean_company,
 
3598
  'primary_color': getattr(self.demo_builder, 'primary_color', '#3498db'),
3599
  'secondary_color': getattr(self.demo_builder, 'secondary_color', '#2c3e50')
3600
  }
3601
+
3602
+ yield f"**Creating Liveboard from Existing Model**\n\nModel: `{existing_model_guid}`\n\n"
3603
+
3604
+ # Auth a deployer so we can pass ts_client to MCP
3605
+ ts_client = ThoughtSpotDeployer(ts_url, ts_user, ts_secret)
3606
+ if not ts_client.authenticate():
3607
+ raise ValueError("ThoughtSpot authentication failed.")
3608
+
3609
+ llm_model = self.settings.get('model', DEFAULT_LLM_MODEL)
3610
+
3611
+ # Create liveboard via HYBRID (MCP) path
3612
+ liveboard_result = create_liveboard_from_model_mcp(
3613
+ ts_client=ts_client,
3614
  model_id=existing_model_guid,
3615
+ model_name="Existing Model",
 
3616
  company_data=company_data,
3617
+ use_case=use_case,
3618
+ num_visualizations=8,
3619
  liveboard_name=liveboard_name,
3620
+ llm_model=llm_model,
3621
+ prompt_logger=self._prompt_logger,
3622
  )
3623
 
3624
  if liveboard_result.get('success'):
 
4785
  with gr.Tab("๐Ÿงฉ Matrix"):
4786
  matrix_components = create_matrix_tab(interface)
4787
 
4788
+ with gr.Tab("๐Ÿ“Š Run History"):
4789
+ gr.Markdown("### Pipeline Run History")
4790
+ gr.Markdown("*Every pipeline run โ€” who ran it, whether it succeeded, and where it failed.*")
4791
+ with gr.Row():
4792
+ run_history_refresh_btn = gr.Button("๐Ÿ”„ Refresh", size="sm")
4793
+ run_history_email_filter = gr.Textbox(label="Filter by email", placeholder="user@company.com", scale=2)
4794
+ run_history_limit = gr.Dropdown(
4795
+ label="Show",
4796
+ choices=["10", "50", "100", "All"],
4797
+ value="10",
4798
+ scale=1,
4799
+ )
4800
+ run_history_display = gr.Dataframe(
4801
+ headers=["Time (UTC)", "User", "Company", "Use Case", "Status", "Failed At", "Duration"],
4802
+ datatype=["str", "str", "str", "str", "str", "str", "str"],
4803
+ column_widths=["130px", "210px", "140px", "160px", "100px", "220px", "80px"],
4804
+ interactive=False,
4805
+ label="Runs",
4806
+ wrap=True,
4807
+ )
4808
+
4809
+ def load_run_history(email_filter="", limit_choice="10"):
4810
+ try:
4811
+ from supabase_client import SupabaseSettings
4812
+ from datetime import datetime as _dt
4813
+ ss = SupabaseSettings()
4814
+ if not ss.is_enabled():
4815
+ return [["Supabase not configured", "", "", "", "", "", ""]]
4816
+
4817
+ display_limit = None if limit_choice == "All" else int(limit_choice)
4818
+ # Fetch enough raw rows to aggregate into desired number of sessions
4819
+ fetch_limit = 2000 if limit_choice == "All" else max(500, (display_limit or 10) * 20)
4820
+
4821
+ query = ss.client.table("session_logs") \
4822
+ .select("session_id,user_email,ts,stage,event,duration_ms,error,meta") \
4823
+ .order("ts", desc=True) \
4824
+ .limit(fetch_limit)
4825
+ if email_filter and email_filter.strip():
4826
+ query = query.eq("user_email", email_filter.strip())
4827
+ result = query.execute()
4828
+ rows = result.data or []
4829
+
4830
+ # Group by session_id
4831
+ sessions = {}
4832
+ for row in rows:
4833
+ sid = row.get('session_id', '')
4834
+ if not sid:
4835
+ continue
4836
+ if sid not in sessions:
4837
+ sessions[sid] = {
4838
+ 'user': row.get('user_email', ''),
4839
+ 'events': [],
4840
+ 'start_ts': row.get('ts', ''),
4841
+ 'end_ts': row.get('ts', ''),
4842
+ 'errors': [],
4843
+ 'stages': [],
4844
+ 'meta': {},
4845
+ }
4846
+ s = sessions[sid]
4847
+ s['events'].append(row.get('event', ''))
4848
+ ts = row.get('ts', '')
4849
+ if ts and ts < s['start_ts']:
4850
+ s['start_ts'] = ts
4851
+ if ts and ts > s['end_ts']:
4852
+ s['end_ts'] = ts
4853
+ if row.get('error'):
4854
+ s['errors'].append(f"{row.get('stage','?')}: {row.get('error','')[:100]}")
4855
+ stage = row.get('stage')
4856
+ if stage and stage not in s['stages']:
4857
+ s['stages'].append(stage)
4858
+ if row.get('meta'):
4859
+ s['meta'].update(row.get('meta') or {})
4860
+
4861
+ sorted_sessions = sorted(sessions.items(), key=lambda x: x[1]['start_ts'], reverse=True)
4862
+ if display_limit:
4863
+ sorted_sessions = sorted_sessions[:display_limit]
4864
+
4865
+ display_rows = []
4866
+ for sid, s in sorted_sessions:
4867
+ has_failed = any('failed' in e for e in s['events'])
4868
+ has_completed = any('completed' in e for e in s['events'])
4869
+ if has_failed:
4870
+ status = 'โŒ Failed'
4871
+ failed_at = s['errors'][0][:80] if s['errors'] else 'unknown'
4872
+ elif has_completed:
4873
+ status = 'โœ… Success'
4874
+ failed_at = ''
4875
+ else:
4876
+ status = 'โณ In Progress'
4877
+ failed_at = ''
4878
+
4879
+ company = s['meta'].get('company', '') or s['meta'].get('company_name', '')
4880
+ use_case = s['meta'].get('use_case', '')
4881
+
4882
+ try:
4883
+ start = _dt.fromisoformat(s['start_ts'].replace('Z', '+00:00'))
4884
+ end = _dt.fromisoformat(s['end_ts'].replace('Z', '+00:00'))
4885
+ dur_s = int((end - start).total_seconds())
4886
+ dur_str = f"{dur_s//60}m {dur_s%60}s" if dur_s >= 60 else f"{dur_s}s"
4887
+ except Exception:
4888
+ dur_str = ''
4889
+
4890
+ display_rows.append([
4891
+ s['start_ts'][:16].replace('T', ' '),
4892
+ s['user'],
4893
+ company,
4894
+ use_case,
4895
+ status,
4896
+ failed_at,
4897
+ dur_str,
4898
+ ])
4899
+
4900
+ return display_rows if display_rows else [["No runs found", "", "", "", "", "", ""]]
4901
+ except Exception as e:
4902
+ return [[f"Error: {e}", "", "", "", "", "", ""]]
4903
+
4904
+ run_history_refresh_btn.click(
4905
+ fn=load_run_history,
4906
+ inputs=[run_history_email_filter, run_history_limit],
4907
+ outputs=[run_history_display]
4908
+ )
4909
+ interface.load(fn=load_run_history, inputs=[], outputs=[run_history_display])
4910
+
4911
  # Check admin status and toggle admin-only settings visibility
4912
  def check_admin_visibility(request: gr.Request):
4913
  """Check if logged-in user is admin and toggle settings visibility."""
 
5301
  msg.submit(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
5302
  send_btn.click(fn=send_message, inputs=_send_inputs, outputs=_send_outputs)
5303
 
5304
+ # App tab: vertical โ†’ line + function cascade
5305
  def update_line_on_vertical(vertical):
5306
  lines = VERTICAL_LINES.get(vertical, [])
5307
  if vertical == "Custom" or not lines:
5308
  return (
5309
  gr.Dropdown(choices=[], value=None, interactive=False),
5310
+ gr.Dropdown(choices=[], value=None, interactive=False),
5311
+ gr.Textbox(label="Context *", placeholder="Describe your use case, industry, and key metrics...", interactive=True),
5312
  )
5313
  return (
5314
  gr.Dropdown(choices=lines, value=lines[0], interactive=True),
5315
+ gr.Dropdown(choices=DEMO_FUNCTIONS, value=DEMO_FUNCTIONS[0], interactive=True),
5316
  gr.Textbox(label="Context", placeholder="Any extra context for the demo...", interactive=True),
5317
  )
5318
 
5319
  vertical_dd.change(
5320
  fn=update_line_on_vertical,
5321
  inputs=[vertical_dd],
5322
+ outputs=[line_dd, function_dd, additional_info_input]
5323
  )
5324
 
5325
  # Defined tab: GO button handler
model_semantic_updater.py CHANGED
@@ -29,7 +29,7 @@ class ModelSemanticUpdater:
29
  # json_object response_format which requires OpenAI.
30
  resolved = resolve_model_name(llm_model)
31
  self.llm_model = resolved if is_openai_model_name(resolved) else resolve_model_name(DEFAULT_LLM_MODEL)
32
- self.openai_client = create_openai_client()
33
 
34
  # ------------------------------------------------------------------
35
  # TML export / import helpers
@@ -166,14 +166,20 @@ Write only the description, nothing else."""
166
  use_case_line = f"\nUse case: {use_case}" if use_case else ""
167
  company_line = f"\nCompany: {company_name}" if company_name else ""
168
 
169
- prompt = f"""You are a data analyst enriching a ThoughtSpot analytics model with semantic metadata.
 
 
 
 
 
 
170
  {company_line}{use_case_line}
171
 
172
  Company/Industry Context:
173
  {research_snippet}
174
 
175
- Columns in this model:
176
- {json.dumps(column_info, indent=2)}
177
 
178
  For EVERY column listed, generate three fields:
179
 
@@ -199,20 +205,23 @@ Return a JSON object keyed by the exact column name:
199
  "ai_context": "..."
200
  }}
201
  }}"""
202
-
203
- try:
204
- token_kwargs = build_openai_chat_token_kwargs(self.llm_model, 4000)
205
- response = self.openai_client.chat.completions.create(
206
- model=self.llm_model,
207
- messages=[{"role": "user", "content": prompt}],
208
- response_format={"type": "json_object"},
209
- temperature=0.3,
210
- **token_kwargs,
211
- )
212
- return json.loads(response.choices[0].message.content)
213
- except Exception as e:
214
- print(f"Error generating column semantics: {e}")
215
- return {}
 
 
 
216
 
217
  # ------------------------------------------------------------------
218
  # TML mutation
 
29
  # json_object response_format which requires OpenAI.
30
  resolved = resolve_model_name(llm_model)
31
  self.llm_model = resolved if is_openai_model_name(resolved) else resolve_model_name(DEFAULT_LLM_MODEL)
32
+ self.openai_client = create_openai_client(timeout=60, max_retries=2)
33
 
34
  # ------------------------------------------------------------------
35
  # TML export / import helpers
 
166
  use_case_line = f"\nUse case: {use_case}" if use_case else ""
167
  company_line = f"\nCompany: {company_name}" if company_name else ""
168
 
169
+ # Batch columns into groups of 25 โ€” 107 columns at 4000 tokens = truncated JSON.
170
+ # Each batch gets its own LLM call so we never hit the output token ceiling.
171
+ BATCH_SIZE = 25
172
+ results: Dict[str, Dict] = {}
173
+ for batch_start in range(0, len(column_info), BATCH_SIZE):
174
+ batch = column_info[batch_start:batch_start + BATCH_SIZE]
175
+ batch_prompt = f"""You are a data analyst enriching a ThoughtSpot analytics model with semantic metadata.
176
  {company_line}{use_case_line}
177
 
178
  Company/Industry Context:
179
  {research_snippet}
180
 
181
+ Columns in this batch:
182
+ {json.dumps(batch, indent=2)}
183
 
184
  For EVERY column listed, generate three fields:
185
 
 
205
  "ai_context": "..."
206
  }}
207
  }}"""
208
+ try:
209
+ # ~200 tokens per column is comfortable for desc + synonyms + ai_context
210
+ batch_max_tokens = max(2000, len(batch) * 200)
211
+ token_kwargs = build_openai_chat_token_kwargs(self.llm_model, batch_max_tokens)
212
+ response = self.openai_client.chat.completions.create(
213
+ model=self.llm_model,
214
+ messages=[{"role": "user", "content": batch_prompt}],
215
+ response_format={"type": "json_object"},
216
+ temperature=0.3,
217
+ **token_kwargs,
218
+ )
219
+ batch_result = json.loads(response.choices[0].message.content)
220
+ results.update(batch_result)
221
+ print(f" [Semantics] Batch {batch_start//BATCH_SIZE + 1}: enriched {len(batch_result)} columns")
222
+ except Exception as e:
223
+ print(f" [Semantics] Batch {batch_start//BATCH_SIZE + 1} failed: {e}")
224
+ return results
225
 
226
  # ------------------------------------------------------------------
227
  # TML mutation
thoughtspot_deployer.py CHANGED
@@ -1737,11 +1737,25 @@ class ThoughtSpotDeployer:
1737
  print(f"[ThoughtSpot] โœ… Tagged {len(object_guids)} {object_type} objects with '{tag_name}'", flush=True)
1738
  return True
1739
  else:
1740
- print(f"[ThoughtSpot] โš ๏ธ Tag assignment failed: {assign_response.status_code}", flush=True)
1741
- print(f"[ThoughtSpot] DEBUG: Response text: {assign_response.text[:500]}", flush=True)
1742
- print(f"[ThoughtSpot] DEBUG: Object GUIDs: {object_guids}", flush=True)
1743
- print(f"[ThoughtSpot] DEBUG: Object type: {object_type}", flush=True)
1744
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1745
 
1746
  except Exception as e:
1747
  print(f"[ThoughtSpot] โš ๏ธ Tag assignment error: {str(e)}", flush=True)
@@ -2225,9 +2239,9 @@ class ThoughtSpotDeployer:
2225
  results['model_guid'] = model_guid
2226
 
2227
  # Assign tag to model
2228
- print(f"๐Ÿ” DEBUG BEFORE TAG CALL: tag_name='{tag_name}', model_guid='{model_guid}'")
2229
- log_progress(f"Assigning tag '{tag_name}' to model...")
2230
- self.assign_tags_to_objects([model_guid], 'LOGICAL_TABLE', tag_name)
2231
 
2232
  # Share model
2233
  _effective_share = share_with or get_admin_setting('SHARE_WITH', required=False)
@@ -2281,7 +2295,10 @@ class ThoughtSpotDeployer:
2281
  # Parse back so we can still dump consistently below
2282
  model_tml_dict = yaml.safe_load(enriched_yaml)
2283
  sem_time = time.time() - sem_start
2284
- log_progress(f"[OK] Semantics generated: {len(column_semantics)} columns enriched ({sem_time:.1f}s)")
 
 
 
2285
  except Exception as sem_err:
2286
  log_progress(f"[WARN] Semantic enrichment failed (non-fatal): {sem_err}")
2287
 
@@ -2557,7 +2574,27 @@ class ThoughtSpotDeployer:
2557
 
2558
  # Mark as successful if we got this far
2559
  results['success'] = len(results['errors']) == 0
2560
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2561
  except Exception as e:
2562
  import traceback
2563
  error_msg = str(e)
 
1737
  print(f"[ThoughtSpot] โœ… Tagged {len(object_guids)} {object_type} objects with '{tag_name}'", flush=True)
1738
  return True
1739
  else:
1740
+ print(f"[ThoughtSpot] โš ๏ธ v1 tag assignment failed ({assign_response.status_code}), trying v2 API...", flush=True)
1741
+ # v1 API returns 404 for models โ€” fall back to v2
1742
+ try:
1743
+ v2_response = self.session.post(
1744
+ f"{self.base_url}/api/rest/2.0/tags/assign",
1745
+ json={
1746
+ "tag_identifiers": [tag_name],
1747
+ "metadata": [{"identifier": guid, "type": object_type} for guid in object_guids]
1748
+ }
1749
+ )
1750
+ if v2_response.status_code in [200, 204]:
1751
+ print(f"[ThoughtSpot] โœ… Tagged {len(object_guids)} {object_type} objects with '{tag_name}' (v2)", flush=True)
1752
+ return True
1753
+ else:
1754
+ print(f"[ThoughtSpot] โš ๏ธ v2 tag assignment also failed: {v2_response.status_code} โ€” {v2_response.text[:300]}", flush=True)
1755
+ return False
1756
+ except Exception as v2_err:
1757
+ print(f"[ThoughtSpot] โš ๏ธ v2 tag assignment error: {v2_err}", flush=True)
1758
+ return False
1759
 
1760
  except Exception as e:
1761
  print(f"[ThoughtSpot] โš ๏ธ Tag assignment error: {str(e)}", flush=True)
 
2239
  results['model_guid'] = model_guid
2240
 
2241
  # Assign tag to model
2242
+ if tag_name and model_guid:
2243
+ log_progress(f"Assigning tag '{tag_name}' to model...")
2244
+ self.assign_tags_to_objects([model_guid], 'LOGICAL_TABLE', tag_name)
2245
 
2246
  # Share model
2247
  _effective_share = share_with or get_admin_setting('SHARE_WITH', required=False)
 
2295
  # Parse back so we can still dump consistently below
2296
  model_tml_dict = yaml.safe_load(enriched_yaml)
2297
  sem_time = time.time() - sem_start
2298
+ if column_semantics:
2299
+ log_progress(f"[OK] Semantics generated: {len(column_semantics)} columns enriched ({sem_time:.1f}s)")
2300
+ else:
2301
+ log_progress(f"[WARN] Semantics generation returned 0 columns โ€” LLM call may have failed ({sem_time:.1f}s)")
2302
  except Exception as sem_err:
2303
  log_progress(f"[WARN] Semantic enrichment failed (non-fatal): {sem_err}")
2304
 
 
2574
 
2575
  # Mark as successful if we got this far
2576
  results['success'] = len(results['errors']) == 0
2577
+
2578
+ # Log summary with clickable links before returning
2579
+ ts_base = self.base_url.rstrip('/')
2580
+ model_guid = results.get('model_guid', '')
2581
+ liveboard_guid = results.get('liveboard_guid', '')
2582
+ lb_url = results.get('liveboard_url', '')
2583
+ if not lb_url and liveboard_guid:
2584
+ lb_url = f"{ts_base}/#/pinboard/{liveboard_guid}"
2585
+ model_url = f"{ts_base}/#/data/tables/{model_guid}" if model_guid else ''
2586
+
2587
+ log_progress("โ”€" * 40)
2588
+ if results['success']:
2589
+ log_progress("โœ… Pipeline complete")
2590
+ else:
2591
+ log_progress(f"โš ๏ธ Pipeline finished with {len(results['errors'])} error(s)")
2592
+ if model_url:
2593
+ log_progress(f"Model: {model_url}")
2594
+ if lb_url:
2595
+ log_progress(f"Liveboard: {lb_url}")
2596
+ log_progress("โ”€" * 40)
2597
+
2598
  except Exception as e:
2599
  import traceback
2600
  error_msg = str(e)