fusesell 1.3.42__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 (35) hide show
  1. fusesell-1.3.42.dist-info/METADATA +873 -0
  2. fusesell-1.3.42.dist-info/RECORD +35 -0
  3. fusesell-1.3.42.dist-info/WHEEL +5 -0
  4. fusesell-1.3.42.dist-info/entry_points.txt +2 -0
  5. fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
  6. fusesell-1.3.42.dist-info/top_level.txt +2 -0
  7. fusesell.py +20 -0
  8. fusesell_local/__init__.py +37 -0
  9. fusesell_local/api.py +343 -0
  10. fusesell_local/cli.py +1480 -0
  11. fusesell_local/config/__init__.py +11 -0
  12. fusesell_local/config/default_email_templates.json +34 -0
  13. fusesell_local/config/default_prompts.json +19 -0
  14. fusesell_local/config/default_scoring_criteria.json +154 -0
  15. fusesell_local/config/prompts.py +245 -0
  16. fusesell_local/config/settings.py +277 -0
  17. fusesell_local/pipeline.py +978 -0
  18. fusesell_local/stages/__init__.py +19 -0
  19. fusesell_local/stages/base_stage.py +603 -0
  20. fusesell_local/stages/data_acquisition.py +1820 -0
  21. fusesell_local/stages/data_preparation.py +1238 -0
  22. fusesell_local/stages/follow_up.py +1728 -0
  23. fusesell_local/stages/initial_outreach.py +2972 -0
  24. fusesell_local/stages/lead_scoring.py +1452 -0
  25. fusesell_local/utils/__init__.py +36 -0
  26. fusesell_local/utils/agent_context.py +552 -0
  27. fusesell_local/utils/auto_setup.py +361 -0
  28. fusesell_local/utils/birthday_email_manager.py +467 -0
  29. fusesell_local/utils/data_manager.py +4857 -0
  30. fusesell_local/utils/event_scheduler.py +959 -0
  31. fusesell_local/utils/llm_client.py +342 -0
  32. fusesell_local/utils/logger.py +203 -0
  33. fusesell_local/utils/output_helpers.py +2443 -0
  34. fusesell_local/utils/timezone_detector.py +914 -0
  35. fusesell_local/utils/validators.py +436 -0
@@ -0,0 +1,2443 @@
1
+ """
2
+ Shared HTML output helper for FuseSell flows.
3
+
4
+ Renders a friendly key/value view and embeds the raw JSON for debugging.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ import json
11
+ import re
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ import base64
16
+ from typing import Any, Dict, List, Optional, Set
17
+ from uuid import uuid4
18
+
19
+ DEFAULT_HIDDEN_KEYS: Set[str] = {"org_id", "org_name", "project_code", "plan_id", "plan_name"}
20
+ DEFAULT_ROOT_HIDDEN_KEYS: Set[str] = {"status", "summary"}
21
+ DEFAULT_HTML_RENDER_KEYS: Set[str] = {"email_body", "body_html", "html_body", "rendered_html"}
22
+
23
+ # CSS theme variables for dark/light mode support
24
+ CSS_THEME_VARS = (
25
+ ":root{"
26
+ "color-scheme:dark light;"
27
+ "--card-bg:#ffffff;"
28
+ "--card-border:#e2e8f0;"
29
+ "--muted:#64748b;"
30
+ "--text:#0f172a;"
31
+ "--accent:#0ea5e9;"
32
+ "--accent-soft:#e0f2fe;"
33
+ "--bg:#f8fafc;"
34
+ "--input-bg:#fff;"
35
+ "--input-border:#d1d5db;"
36
+ "--shadow:rgba(15,23,42,0.05);"
37
+ "--shadow-dark:rgba(15,23,42,0.07);"
38
+ "--pre-bg:#0f172a;"
39
+ "--pre-text:#e2e8f0;"
40
+ "--badge-bg:#c7d2fe;"
41
+ "}"
42
+ ".dark{"
43
+ "--card-bg:#1e293b;"
44
+ "--card-border:#334155;"
45
+ "--muted:#94a3b8;"
46
+ "--text:#f1f5f9;"
47
+ "--accent:#3b82f6;"
48
+ "--accent-soft:#1e3a5f;"
49
+ "--bg:#0f172a;"
50
+ "--input-bg:#0f172a;"
51
+ "--input-border:#475569;"
52
+ "--shadow:rgba(0,0,0,0.2);"
53
+ "--shadow-dark:rgba(0,0,0,0.3);"
54
+ "--pre-bg:#1e293b;"
55
+ "--pre-text:#f1f5f9;"
56
+ "--badge-bg:#4c5c7d;"
57
+ "}"
58
+ )
59
+
60
+
61
+ def _sanitize_for_json(value: Any) -> Any:
62
+ if isinstance(value, dict):
63
+ return {key: _sanitize_for_json(val) for key, val in value.items()}
64
+ if isinstance(value, list):
65
+ return [_sanitize_for_json(item) for item in value]
66
+ if isinstance(value, (str, int, float, bool)) or value is None:
67
+ return value
68
+ return str(value)
69
+
70
+
71
+ def _prune_empty(value: Any, *, depth: int = 0, hidden_keys: Set[str], root_hidden_keys: Set[str]) -> Any:
72
+ if value is None:
73
+ return None
74
+ if isinstance(value, str):
75
+ stripped = value.strip()
76
+ return stripped or None
77
+ if isinstance(value, dict):
78
+ pruned: Dict[str, Any] = {}
79
+ for k, v in value.items():
80
+ k_lower = str(k).lower()
81
+ if k_lower in hidden_keys:
82
+ continue
83
+ if depth == 0 and k_lower in root_hidden_keys:
84
+ continue
85
+ pruned_val = _prune_empty(v, depth=depth + 1, hidden_keys=hidden_keys, root_hidden_keys=root_hidden_keys)
86
+ if pruned_val is not None:
87
+ pruned[k] = pruned_val
88
+ return pruned or None
89
+ if isinstance(value, list):
90
+ pruned_list = [
91
+ _prune_empty(item, depth=depth + 1, hidden_keys=hidden_keys, root_hidden_keys=root_hidden_keys)
92
+ for item in value
93
+ ]
94
+ pruned_list = [item for item in pruned_list if item is not None]
95
+ return pruned_list or None
96
+ return value
97
+
98
+
99
+ def _friendly_key(key: str) -> str:
100
+ if not isinstance(key, str):
101
+ return str(key)
102
+ spaced: List[str] = []
103
+ previous = ""
104
+ for char in key:
105
+ if previous and previous.islower() and char.isupper():
106
+ spaced.append(" ")
107
+ spaced.append(char)
108
+ previous = char
109
+ cleaned = "".join(spaced)
110
+ cleaned = cleaned.replace("_", " ").replace("-", " ")
111
+ cleaned = " ".join(cleaned.split())
112
+ return cleaned.title() if cleaned else key
113
+
114
+
115
+ def _humanize_keys(value: Any) -> Any:
116
+ if isinstance(value, dict):
117
+ return {_friendly_key(key): _humanize_keys(val) for key, val in value.items()}
118
+ if isinstance(value, list):
119
+ return [_humanize_keys(item) for item in value]
120
+ return value
121
+
122
+
123
+ def _is_complex_array(value: Any) -> bool:
124
+ return isinstance(value, list) and any(isinstance(item, (dict, list)) for item in value)
125
+
126
+
127
+ def _render_value(value: Any, depth: int = 0, key: Optional[str] = None, html_render_keys: Set[str] = None) -> str:
128
+ html_render_keys = html_render_keys or set()
129
+ indent_px = depth * 14
130
+ if isinstance(value, dict):
131
+ parts = []
132
+ for child_key, val in value.items():
133
+ if _is_complex_array(val):
134
+ parts.append(
135
+ f"<div class='section-header' style='margin-left:{indent_px}px'>"
136
+ f"{html.escape(_friendly_key(str(child_key)))}"
137
+ "</div>"
138
+ )
139
+ parts.append(_render_value(val, depth, child_key, html_render_keys))
140
+ else:
141
+ parts.append(
142
+ "<div class='kv' style='margin-left:"
143
+ f"{indent_px}px'>"
144
+ f"<div class='k'>{html.escape(_friendly_key(str(child_key)))}</div>"
145
+ f"<div class='v'>{_render_value(val, depth + 1, str(child_key), html_render_keys)}</div>"
146
+ "</div>"
147
+ )
148
+ return "".join(parts)
149
+ if isinstance(value, list):
150
+ if not any(isinstance(item, (dict, list)) for item in value):
151
+ chips = "".join(f"<span class='chip'>{html.escape(str(item))}</span>" for item in value)
152
+ return f"<div class='chips' style='margin-left:{indent_px}px'>{chips}</div>"
153
+ parts = []
154
+ array_indent_px = indent_px + 20
155
+ for item in value:
156
+ parts.append(
157
+ "<div class='list-item' style='margin-left:"
158
+ f"{array_indent_px}px'>"
159
+ f"{_render_value(item, depth + 1, None, html_render_keys)}"
160
+ "</div>"
161
+ )
162
+ return "".join(parts)
163
+ if isinstance(value, str):
164
+ normalized_key = (key or "").strip().lower().replace(" ", "_").replace("-", "_")
165
+ if normalized_key in html_render_keys:
166
+ escaped_srcdoc = html.escape(value, quote=True)
167
+ escaped_raw = html.escape(value)
168
+ return (
169
+ "<div class='html-preview'>"
170
+ "<div class='html-preview-label'>Rendered HTML</div>"
171
+ "<iframe class='html-iframe' sandbox srcdoc=\""
172
+ f"{escaped_srcdoc}"
173
+ "\"></iframe>"
174
+ "<details class='html-raw'>"
175
+ "<summary>Show HTML Source</summary>"
176
+ "<div class='pre-inline'>"
177
+ f"{escaped_raw}"
178
+ "</div>"
179
+ "</details>"
180
+ "</div>"
181
+ )
182
+ return f"<span class='text'>{html.escape(str(value))}</span>"
183
+
184
+
185
+ def _normalize_duration(value: Any) -> str:
186
+ """Format a duration-like value to two decimals when possible."""
187
+ try:
188
+ return f"{float(value):.2f}"
189
+ except (TypeError, ValueError):
190
+ return _first_non_empty(value)
191
+
192
+
193
+ def _strip_keys(value: Any, *, drop_keys: Set[str], drop_id_like: bool = False) -> Any:
194
+ """Recursively drop keys and optionally any key containing '_id'."""
195
+ normalized_drop = {k.replace("_", "").replace("-", "") for k in drop_keys}
196
+ if isinstance(value, dict):
197
+ cleaned: Dict[str, Any] = {}
198
+ for key, val in value.items():
199
+ key_norm = str(key).lower().replace("_", "").replace("-", "")
200
+ if key_norm in normalized_drop:
201
+ continue
202
+ lower_key = str(key).lower()
203
+ if drop_id_like and ("_id" in lower_key or key_norm.endswith("id")):
204
+ continue
205
+ cleaned_val = _strip_keys(val, drop_keys=drop_keys, drop_id_like=drop_id_like)
206
+ if cleaned_val in (None, {}, []):
207
+ continue
208
+ cleaned[key] = cleaned_val
209
+ return cleaned or None
210
+ if isinstance(value, list):
211
+ cleaned_list = [
212
+ _strip_keys(item, drop_keys=drop_keys, drop_id_like=drop_id_like)
213
+ for item in value
214
+ ]
215
+ cleaned_list = [item for item in cleaned_list if item not in (None, {}, [])]
216
+ return cleaned_list or None
217
+ if isinstance(value, str):
218
+ stripped = value.strip()
219
+ if not stripped:
220
+ return None
221
+ return stripped
222
+ return value
223
+
224
+ # =============================================================================
225
+ # Sales Process Rendering Helpers
226
+ # =============================================================================
227
+
228
+ def _format_percent(value: Any) -> str:
229
+ """Format percentages with two decimal places."""
230
+ if value is None:
231
+ return ""
232
+ try:
233
+ number = float(value)
234
+ except (TypeError, ValueError):
235
+ return str(value)
236
+ return f"{number:,.2f}%"
237
+
238
+
239
+ def _format_number(value: Any) -> str:
240
+ """Format numbers with thousands separators."""
241
+ if value is None:
242
+ return ""
243
+ if isinstance(value, bool):
244
+ return str(value)
245
+ if isinstance(value, int):
246
+ return f"{value:,}"
247
+ try:
248
+ number = float(value)
249
+ except (TypeError, ValueError):
250
+ return str(value)
251
+ return f"{number:,.2f}"
252
+
253
+
254
+ def _format_timestamp(value: Any) -> str:
255
+ """
256
+ Format timestamps as local time strings (HH:MM DD/MM/YYYY) when possible.
257
+
258
+ Assumptions:
259
+ - ISO strings are treated as UTC if naive; otherwise respected and converted to local.
260
+ - Numeric values >1e12 are treated as milliseconds.
261
+ """
262
+ if value is None:
263
+ return ""
264
+
265
+ try:
266
+ local_tz = datetime.now().astimezone().tzinfo
267
+ except Exception:
268
+ local_tz = None
269
+
270
+ def _to_local(dt: datetime) -> str:
271
+ if dt.tzinfo is None:
272
+ dt = dt.replace(tzinfo=timezone.utc)
273
+ if local_tz:
274
+ dt = dt.astimezone(local_tz)
275
+ return dt.strftime("%H:%M %d/%m/%Y")
276
+
277
+ if isinstance(value, (int, float)):
278
+ try:
279
+ ts = float(value)
280
+ if ts > 1e12: # Likely milliseconds
281
+ ts = ts / 1000.0
282
+ dt = datetime.fromtimestamp(ts, tz=timezone.utc)
283
+ return _to_local(dt)
284
+ except (OSError, OverflowError, ValueError):
285
+ return str(value)
286
+
287
+ if isinstance(value, str):
288
+ cleaned = value.strip()
289
+ if not cleaned:
290
+ return ""
291
+ try:
292
+ dt = datetime.fromisoformat(cleaned.replace("Z", "+00:00"))
293
+ return _to_local(dt)
294
+ except ValueError:
295
+ return cleaned
296
+
297
+ return str(value)
298
+
299
+
300
+ def _first_non_empty(*values: Any) -> str:
301
+ """Return the first non-empty value from the arguments."""
302
+ for value in values:
303
+ if value is None:
304
+ continue
305
+ if isinstance(value, str):
306
+ stripped = value.strip()
307
+ if stripped:
308
+ return stripped
309
+ elif isinstance(value, (int, float)):
310
+ return _format_number(value)
311
+ elif isinstance(value, list):
312
+ if value:
313
+ return ", ".join(str(item) for item in value if item is not None)
314
+ else:
315
+ return str(value)
316
+ return ""
317
+
318
+
319
+ def _normalize_duration(value: Any) -> str:
320
+ """Format a duration-like value to two decimal places when possible."""
321
+ try:
322
+ return f"{float(value):.2f}"
323
+ except (TypeError, ValueError):
324
+ return _first_non_empty(value)
325
+
326
+
327
+ def _filter_primary_drafts(drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
328
+ """Return priority_order 1 drafts only; fallback to the first draft."""
329
+ if not drafts:
330
+ return drafts
331
+
332
+ primary: List[Dict[str, Any]] = []
333
+ for draft in drafts:
334
+ if not isinstance(draft, dict):
335
+ continue
336
+ try:
337
+ priority_val = int(draft.get("priority_order"))
338
+ except (TypeError, ValueError):
339
+ priority_val = None
340
+ if priority_val == 1:
341
+ primary.append(draft)
342
+
343
+ if primary:
344
+ return primary
345
+ return drafts[:1]
346
+
347
+
348
+ def _row(label: str, value: str) -> str:
349
+ """Generate a table row with label and value."""
350
+ return f"<tr><th>{html.escape(label)}</th><td>{html.escape(value) if value else ''}</td></tr>"
351
+
352
+
353
+ def _render_filters(filters: Dict[str, Any]) -> str:
354
+ """Render query filters as table rows."""
355
+ if not isinstance(filters, dict):
356
+ return ""
357
+ rows = [
358
+ _row("Org Id", _first_non_empty(filters.get('org_id'))),
359
+ _row("Customer Name", _first_non_empty(filters.get('customer_name'))),
360
+ _row("Status", _first_non_empty(filters.get('status'))),
361
+ _row("Limit", _first_non_empty(filters.get('limit'))),
362
+ _row("Include Operations", str(bool(filters.get('include_operations')))),
363
+ _row("Include Scores", str(bool(filters.get('include_scores')))),
364
+ ]
365
+ return "".join(rows)
366
+
367
+
368
+ def _render_customer_details(details: Optional[Dict[str, Any]]) -> str:
369
+ """Render customer details as table rows. FIX: Flatten nested profile_data and filter empty fields."""
370
+ if not isinstance(details, dict) or not details:
371
+ return "<div class='muted'>No customer details available.</div>"
372
+
373
+ rows = []
374
+ for key, val in details.items():
375
+ # Filter out empty values
376
+ if val is None or val == "" or val == [] or val == {}:
377
+ continue
378
+
379
+ label = _friendly_key(str(key))
380
+ # FIX #3: Detect and flatten nested profile_data
381
+ if key == "profile_data" and isinstance(val, dict):
382
+ # Check if there's a nested profile_data inside
383
+ inner_profile = val.get("profile_data")
384
+ if isinstance(inner_profile, dict):
385
+ # Use the inner profile_data and flatten it
386
+ for inner_k, inner_v in inner_profile.items():
387
+ # Filter empty inner values
388
+ if inner_v is None or inner_v == "" or inner_v == [] or inner_v == {}:
389
+ continue
390
+ if isinstance(inner_v, (dict, list)):
391
+ # Render complex values as nested structure
392
+ inner_html = "<div class='nested-card'>" + _render_value(inner_v) + "</div>"
393
+ rows.append(f"<tr><th>{html.escape(_friendly_key(str(inner_k)))}</th><td>{inner_html}</td></tr>")
394
+ else:
395
+ rows.append(_row(_friendly_key(str(inner_k)), _first_non_empty(inner_v)))
396
+ else:
397
+ # No nested profile_data, render the dict normally
398
+ inner_rows = []
399
+ for inner_k, inner_v in val.items():
400
+ # Filter empty inner values
401
+ if inner_v is None or inner_v == "" or inner_v == [] or inner_v == {}:
402
+ continue
403
+ if isinstance(inner_v, (dict, list)):
404
+ inner_html = "<div class='nested-card'>" + _render_value(inner_v) + "</div>"
405
+ inner_rows.append(f"<tr><th>{html.escape(_friendly_key(str(inner_k)))}</th><td>{inner_html}</td></tr>")
406
+ else:
407
+ inner_rows.append(_row(_friendly_key(str(inner_k)), _first_non_empty(inner_v)))
408
+ if inner_rows: # Only add if there are non-empty rows
409
+ rows.append(
410
+ "<tr><th>Profile Data</th><td><table>"
411
+ + "".join(inner_rows)
412
+ + "</table></td></tr>"
413
+ )
414
+ else:
415
+ rows.append(_row(label, _first_non_empty(val)))
416
+ return "".join(rows) if rows else "<div class='muted'>No customer details available.</div>"
417
+
418
+
419
+ def _render_lead_scores(lead_scores: Any) -> str:
420
+ """Render lead scores as table rows. FIX: Render criteria_breakdown as structured data and optimize layout."""
421
+ if not isinstance(lead_scores, list) or not lead_scores:
422
+ return "<tr><td colspan='3'>No lead scores found.</td></tr>"
423
+ parts = []
424
+ for entry in lead_scores:
425
+ if not isinstance(entry, dict):
426
+ continue
427
+ criteria = entry.get("criteria_breakdown")
428
+ criteria_html = ""
429
+ # FIX #1: Render criteria_breakdown as structured data instead of raw JSON
430
+ if isinstance(criteria, dict):
431
+ crit_rows = []
432
+ idx = 0
433
+ for c_key, c_val in criteria.items():
434
+ idx += 1
435
+ # Filter out empty values
436
+ if c_val is None or c_val == "" or c_val == {} or c_val == []:
437
+ continue
438
+
439
+ # Check if the value is a dict with score/justification
440
+ if isinstance(c_val, dict):
441
+ score = c_val.get('score', '')
442
+ justification = c_val.get('justification', '')
443
+ # Skip if both are empty
444
+ if not score and not justification:
445
+ continue
446
+ # Optimized layout: number and category on same line, content below
447
+ crit_rows.append(
448
+ f"<div class='criteria-item'>"
449
+ f"<div class='criteria-header'>"
450
+ f"<span class='criteria-num'>{idx}</span>"
451
+ f"<span class='criteria-label'>{html.escape(_friendly_key(str(c_key)))}</span>"
452
+ f"</div>"
453
+ f"<div class='criteria-content'>"
454
+ f"<div><strong>Score:</strong> {html.escape(str(score))}</div>"
455
+ f"<div>{html.escape(str(justification))}</div>"
456
+ f"</div>"
457
+ f"</div>"
458
+ )
459
+ else:
460
+ val_str = _first_non_empty(c_val)
461
+ if val_str:
462
+ crit_rows.append(
463
+ f"<div class='criteria-item'>"
464
+ f"<div class='criteria-header'>"
465
+ f"<span class='criteria-num'>{idx}</span>"
466
+ f"<span class='criteria-label'>{html.escape(_friendly_key(str(c_key)))}</span>"
467
+ f"</div>"
468
+ f"<div class='criteria-content'>{html.escape(val_str)}</div>"
469
+ f"</div>"
470
+ )
471
+ if crit_rows:
472
+ criteria_html = "<div class='criteria-list'>" + "".join(crit_rows) + "</div>"
473
+ parts.append(
474
+ "<tr>"
475
+ f"<td>{html.escape(_first_non_empty(entry.get('product_name') or entry.get('product_id')))}</td>"
476
+ f"<td>{html.escape(_format_number(entry.get('score')))}</td>"
477
+ f"<td>{html.escape(_format_timestamp(entry.get('created_at')))}</td>"
478
+ "</tr>"
479
+ + (f"<tr><td colspan='3'>{criteria_html}</td></tr>" if criteria_html else "")
480
+ )
481
+ return "".join(parts) or "<tr><td colspan='3'>No lead scores found.</td></tr>"
482
+
483
+
484
+ def _render_email_drafts(drafts: Any) -> str:
485
+ """Render email drafts with rendered HTML preview."""
486
+ if not isinstance(drafts, list) or not drafts:
487
+ return "<div class='muted'>No email drafts found.</div>"
488
+ drafts = _filter_primary_drafts(drafts)
489
+ if not drafts:
490
+ return "<div class='muted'>No email drafts found.</div>"
491
+ seen = set()
492
+ rows = []
493
+ for idx, draft in enumerate(drafts, start=1):
494
+ if not isinstance(draft, dict):
495
+ continue
496
+ key = draft.get('draft_id') or draft.get('id') or id(draft)
497
+ if key in seen:
498
+ continue
499
+ seen.add(key)
500
+ subject = draft.get("subject") or f"Draft {idx}"
501
+ created = _format_timestamp(draft.get("created_at"))
502
+ draft_type = _first_non_empty(draft.get("draft_type") or draft.get("type"))
503
+ priority = _first_non_empty(
504
+ draft.get("priority_order"),
505
+ (draft.get("metadata") or {}).get("priority_order"),
506
+ )
507
+ chips = []
508
+ if priority:
509
+ chips.append(f"<span class='chip'>Priority {html.escape(str(priority))}</span>")
510
+ if draft_type:
511
+ chips.append(f"<span class='chip'>{html.escape(draft_type)}</span>")
512
+ if created:
513
+ chips.append(f"<span class='chip'>{html.escape(created)}</span>")
514
+ meta = "".join(chips)
515
+ body_html = draft.get("content") or draft.get("email_body") or draft.get("body_html") or ""
516
+ escaped_srcdoc = html.escape(body_html, quote=True)
517
+ rows.append(
518
+ "<div class='draft'>"
519
+ f"<h4>{html.escape(subject)}</h4>"
520
+ + (f"<div class='chips'>{meta}</div>" if meta else "")
521
+ + f"<iframe class='iframe-draft' sandbox srcdoc=\"{escaped_srcdoc}\" loading='lazy'></iframe>"
522
+ + "</div>"
523
+ )
524
+ return "".join(rows)
525
+
526
+
527
+ def _render_all_email_drafts(drafts: Any, *, show_schedule: bool = False) -> str:
528
+ """Render all email drafts (no filtering) with rendered HTML preview."""
529
+ if not isinstance(drafts, list) or not drafts:
530
+ return "<div class='muted'>No email drafts found.</div>"
531
+
532
+ seen = set()
533
+ rows = []
534
+ for idx, draft in enumerate(drafts, start=1):
535
+ if not isinstance(draft, dict):
536
+ continue
537
+ key = draft.get("draft_id") or draft.get("id") or id(draft)
538
+ if key in seen:
539
+ continue
540
+ seen.add(key)
541
+
542
+ subject = draft.get("subject") or f"Draft {idx}"
543
+ created = _format_timestamp(draft.get("created_at"))
544
+ draft_type = _first_non_empty(draft.get("draft_type") or draft.get("type"))
545
+ priority = _first_non_empty(
546
+ draft.get("priority_order"),
547
+ (draft.get("metadata") or {}).get("priority_order"),
548
+ )
549
+ chips = []
550
+ if priority:
551
+ chips.append(f"<span class='chip strong'>Priority {html.escape(str(priority))}</span>")
552
+ if draft_type:
553
+ chips.append(f"<span class='chip'>{html.escape(draft_type)}</span>")
554
+ if created:
555
+ chips.append(f"<span class='chip muted-chip'>{html.escape(created)}</span>")
556
+ chips.append(f"<span class='chip muted-chip'>#{idx}</span>")
557
+ if key:
558
+ chips.append(f"<span class='chip muted-chip'>ID: {html.escape(str(key))}</span>")
559
+
560
+ if show_schedule:
561
+ schedule = draft.get("scheduled_send") if isinstance(draft.get("scheduled_send"), dict) else {}
562
+ scheduled_time = schedule.get("scheduled_time") or schedule.get("scheduled_time_utc")
563
+ cron_expr = schedule.get("cron_utc") or schedule.get("cron")
564
+ status = schedule.get("status")
565
+ if draft.get("selected_for_send"):
566
+ chips.append("<span class='chip success'>Selected for send</span>")
567
+ if scheduled_time:
568
+ chips.append(f"<span class='chip warn'>Scheduled</span>")
569
+ if cron_expr:
570
+ chips.append(f"<span class='chip muted-chip'>Cron {html.escape(str(cron_expr))}</span>")
571
+ if status:
572
+ chips.append(f"<span class='chip muted-chip'>{html.escape(str(status))}</span>")
573
+ else:
574
+ scheduled_time = None
575
+ status = None
576
+
577
+ schedule_html = ""
578
+ if show_schedule and scheduled_time:
579
+ schedule_html = (
580
+ "<div class='schedule-row'>"
581
+ "<span class='pill pill-warn'>Scheduled Send</span>"
582
+ f"<span class='schedule-time'>{html.escape(_format_timestamp(scheduled_time))}</span>"
583
+ + (f"<span class='pill pill-muted'>{html.escape(str(status))}</span>" if status else "")
584
+ + "</div>"
585
+ )
586
+
587
+ body_html = (
588
+ draft.get("body_html")
589
+ or draft.get("content")
590
+ or draft.get("email_body")
591
+ or draft.get("html_body")
592
+ or ""
593
+ )
594
+ body_section = "<div class='muted'>No body provided.</div>"
595
+ if body_html:
596
+ escaped_srcdoc = html.escape(str(body_html), quote=True)
597
+ body_section = (
598
+ "<div class='html-preview'>"
599
+ f"<iframe class='iframe-draft' sandbox srcdoc=\"{escaped_srcdoc}\" loading='lazy'></iframe>"
600
+ "</div>"
601
+ )
602
+
603
+ rows.append(
604
+ "<div class='draft'>"
605
+ f"<div class='draft-header'><span class='draft-label'>Draft {idx}</span><h4>{html.escape(subject)}</h4></div>"
606
+ + (f"<div class='chips'>{''.join(chips)}</div>" if chips else "")
607
+ + schedule_html
608
+ + body_section
609
+ + "</div>"
610
+ )
611
+ return "".join(rows) or "<div class='muted'>No email drafts found.</div>"
612
+
613
+
614
+ def _render_stage_cards(stages: Any) -> str:
615
+ """Render stages as stacked cards with cleaned fields."""
616
+ if not isinstance(stages, list) or not stages:
617
+ return "<div class='muted'>No stages available.</div>"
618
+
619
+ cards: List[str] = []
620
+ for idx, stage in enumerate(stages, start=1):
621
+ if not isinstance(stage, dict):
622
+ continue
623
+
624
+ stage_name = _friendly_key(
625
+ str(
626
+ stage.get("stage_name")
627
+ or stage.get("stage")
628
+ or stage.get("executor_name")
629
+ or stage.get("executor")
630
+ or f"Stage {idx}"
631
+ )
632
+ )
633
+
634
+ # Clean top-level fields
635
+ cleaned = _strip_keys(
636
+ stage,
637
+ drop_keys={"chain_index", "executor_name", "executor", "timing", "start_time", "end_time", "stage"},
638
+ drop_id_like=True,
639
+ ) or {}
640
+
641
+ output_data = cleaned.pop("output_data", None)
642
+ draft_section = ""
643
+ primary_priority = None
644
+ if isinstance(output_data, dict):
645
+ # Capture drafts before stripping keys
646
+ drafts = output_data.get("drafts") or output_data.get("email_drafts")
647
+ nested_data = output_data.get("data")
648
+ if isinstance(nested_data, dict):
649
+ drafts = drafts or nested_data.get("drafts") or nested_data.get("email_drafts")
650
+
651
+ output_data = _strip_keys(
652
+ output_data,
653
+ drop_keys={"start_time", "end_time", "stage", "timing", "drafts", "email_drafts"},
654
+ drop_id_like=True,
655
+ ) or None
656
+
657
+ # Remove any lingering draft keys after stripping
658
+ if isinstance(output_data, dict):
659
+ output_data.pop("drafts", None)
660
+ output_data.pop("email_drafts", None)
661
+ data_block = output_data.get("data")
662
+ if isinstance(data_block, dict):
663
+ data_block.pop("drafts", None)
664
+ data_block.pop("email_drafts", None)
665
+ if not data_block:
666
+ output_data.pop("data", None)
667
+
668
+ if isinstance(drafts, list) and drafts:
669
+ drafts = _filter_primary_drafts(drafts)
670
+ if drafts:
671
+ primary_priority = drafts[0].get("priority_order") or (drafts[0].get("metadata") or {}).get("priority_order")
672
+ first = drafts[0]
673
+ subject = _first_non_empty(first.get("subject"), "Email Draft")
674
+ body = first.get("email_body") or first.get("body_html") or first.get("content") or ""
675
+ recipient = _first_non_empty(first.get("recipient_name") or (first.get("metadata") or {}).get("recipient_name"))
676
+ email_type = _first_non_empty(
677
+ first.get("email_type")
678
+ or (first.get("metadata") or {}).get("email_type")
679
+ or first.get("draft_type")
680
+ or first.get("type")
681
+ )
682
+ chips = []
683
+ if primary_priority is not None:
684
+ chips.append(f"<span class='chip'>Priority {html.escape(str(primary_priority))}</span>")
685
+ if recipient:
686
+ chips.append(f"<span class='chip'>{html.escape(recipient)}</span>")
687
+ if email_type:
688
+ chips.append(f"<span class='chip'>{html.escape(email_type)}</span>")
689
+
690
+ iframe = (
691
+ "<div class='html-preview'>"
692
+ "<div class='html-preview-label'>Rendered HTML</div>"
693
+ f"<iframe class='html-iframe' sandbox srcdoc=\"{html.escape(body, quote=True)}\"></iframe>"
694
+ "</div>"
695
+ )
696
+
697
+ draft_section = (
698
+ "<div class='stage-subsection'>"
699
+ "<h4>Email Draft</h4>"
700
+ f"<h5>{html.escape(subject)}</h5>"
701
+ + ("<div class='chips'>" + "".join(chips) + "</div>" if chips else "")
702
+ + iframe
703
+ + "</div>"
704
+ )
705
+
706
+ # Hide the raw output_data block when showing the draft to avoid clutter
707
+ output_data = None
708
+
709
+ if isinstance(output_data, dict) and "duration_seconds" in output_data:
710
+ output_data["duration_seconds"] = _normalize_duration(output_data.get("duration_seconds"))
711
+
712
+ # Normalize durations
713
+ if "duration_seconds" in cleaned:
714
+ cleaned["duration_seconds"] = _normalize_duration(cleaned.get("duration_seconds"))
715
+
716
+ # Remove stage labels from cleaned after capturing
717
+ cleaned.pop("stage_name", None)
718
+ cleaned.pop("stage", None)
719
+
720
+ status = cleaned.pop("execution_status", None) or cleaned.pop("status", None)
721
+ order = cleaned.pop("runtime_index", None) or cleaned.pop("order", None)
722
+ created = cleaned.pop("created_at", None) or cleaned.pop("updated_at", None)
723
+
724
+ meta_rows: List[str] = []
725
+ if primary_priority is not None:
726
+ meta_rows.append(f"<div class='kv'><div class='k'>Priority Order</div><div class='v'>{html.escape(str(primary_priority))}</div></div>")
727
+ if status:
728
+ meta_rows.append(f"<div class='kv'><div class='k'>Status</div><div class='v'>{html.escape(_friendly_key(str(status)))}</div></div>")
729
+ if "duration_seconds" in cleaned and cleaned.get("duration_seconds") is not None:
730
+ meta_rows.append(f"<div class='kv'><div class='k'>Duration Seconds</div><div class='v'>{html.escape(str(cleaned.get('duration_seconds')))}</div></div>")
731
+ cleaned.pop("duration_seconds", None)
732
+ if order is not None and order != "":
733
+ meta_rows.append(f"<div class='kv'><div class='k'>Order</div><div class='v'>{html.escape(_friendly_key(str(order)))}</div></div>")
734
+ if created:
735
+ meta_rows.append(f"<div class='kv'><div class='k'>Created At</div><div class='v'>{html.escape(_friendly_key(str(created)))}</div></div>")
736
+
737
+ # Render remaining fields (excluding output_data already handled)
738
+ for key, val in list(cleaned.items()):
739
+ if val in (None, {}, []):
740
+ continue
741
+ label = _friendly_key(str(key))
742
+ if isinstance(val, (dict, list)):
743
+ meta_rows.append(f"<div class='kv'><div class='k'>{html.escape(label)}</div><div class='v'>{_render_value(val)}</div></div>")
744
+ else:
745
+ meta_rows.append(f"<div class='kv'><div class='k'>{html.escape(label)}</div><div class='v'>{html.escape(_friendly_key(str(val)))}</div></div>")
746
+
747
+ body_parts: List[str] = []
748
+ if meta_rows:
749
+ body_parts.append("".join(meta_rows))
750
+ if output_data:
751
+ body_parts.append(
752
+ "<div class='stage-subsection'>"
753
+ "<h4>Output Data</h4>"
754
+ f"{_render_value(output_data, html_render_keys=DEFAULT_HTML_RENDER_KEYS)}"
755
+ "</div>"
756
+ )
757
+ if draft_section:
758
+ body_parts.append(
759
+ "<div class='stage-subsection'>"
760
+ f"{draft_section}"
761
+ "</div>"
762
+ )
763
+
764
+ cards.append(
765
+ "<div class='stage-card'>"
766
+ "<div class='stage-header'>"
767
+ f"<span class='stage-badge'>#{idx}</span>"
768
+ f"<div class='stage-title'>{html.escape(stage_name)}</div>"
769
+ "</div>"
770
+ + "".join(body_parts)
771
+ + "</div>"
772
+ )
773
+
774
+ return "".join(cards) or "<div class='muted'>No stages available.</div>"
775
+
776
+
777
+ def _render_query_results(payload: Dict[str, Any], raw_json: str) -> str:
778
+ """Custom renderer for query sales processes."""
779
+ filters = payload.get("filters") if isinstance(payload, dict) else {}
780
+ results = payload.get("results") if isinstance(payload, dict) else []
781
+ raw_escaped = html.escape(raw_json)
782
+
783
+ filter_rows = []
784
+ if isinstance(filters, dict):
785
+ for key in ("org_id", "customer_name", "status", "limit", "include_operations", "include_scores"):
786
+ val = filters.get(key)
787
+ if val not in (None, "", [], {}):
788
+ filter_rows.append(f"<div class='kv'><div class='k'>{html.escape(_friendly_key(str(key)))}</div><div class='v'>{html.escape(str(val))}</div></div>")
789
+
790
+ cards: List[str] = []
791
+ if isinstance(results, list):
792
+ for idx, item in enumerate(results, start=1):
793
+ if not isinstance(item, dict):
794
+ continue
795
+
796
+ cleaned_item = _strip_keys(item, drop_keys=set(), drop_id_like=True) or {}
797
+
798
+ # Task overview (drop customer details)
799
+ overview_rows = []
800
+ for key in ("task_id", "status", "customer", "team_name", "team_id", "current_stage", "created_at", "updated_at"):
801
+ val = cleaned_item.get(key)
802
+ if val not in (None, "", [], {}):
803
+ overview_rows.append(f"<div class='kv'><div class='k'>{html.escape(_friendly_key(str(key)))}</div><div class='v'>{html.escape(str(val))}</div></div>")
804
+
805
+ lead_scores = cleaned_item.get("lead_scores")
806
+ lead_block = ""
807
+ if isinstance(lead_scores, list) and lead_scores:
808
+ lead_parts: List[str] = []
809
+ for score in lead_scores:
810
+ if not isinstance(score, dict):
811
+ continue
812
+ score_clean = _strip_keys(score, drop_keys=set(), drop_id_like=True) or {}
813
+ product = score_clean.get("product_name") or score_clean.get("product_id") or ""
814
+ val = score_clean.get("score")
815
+ created = score_clean.get("created_at")
816
+ lead_parts.append(
817
+ "<div class='kv'>"
818
+ f"<div class='k'>{html.escape(_friendly_key(str(product or 'Product')))}</div>"
819
+ f"<div class='v'>{html.escape(_friendly_key(str(val)))}"
820
+ + (f" <span class='chip'>{html.escape(_friendly_key(str(created)))}</span>" if created else "")
821
+ + "</div></div>"
822
+ )
823
+ lead_block = "".join(lead_parts)
824
+
825
+ stage_cards = _render_stage_cards(cleaned_item.get("stages"))
826
+
827
+ cards.append(
828
+ (
829
+ "<div class='card'>"
830
+ "<div class='card-header'>"
831
+ f"<span class='result-badge'>#{idx}</span>"
832
+ f"<h2>Result {idx}</h2>"
833
+ "</div>"
834
+ "<div class='section'>"
835
+ "<h3>Task Overview</h3>"
836
+ f"{''.join(overview_rows) if overview_rows else '<div class=\"muted\">No task info.</div>'}"
837
+ "</div>"
838
+ + (
839
+ "<div class='section'><h3>Lead Scores</h3>" + (lead_block or "<div class='muted'>No lead scores.</div>") + "</div>"
840
+ if lead_scores else ""
841
+ )
842
+ + "<div class='section'><h3>Stages</h3>" + stage_cards + "</div>"
843
+ + "</div>"
844
+ )
845
+ )
846
+
847
+ style = (
848
+ CSS_THEME_VARS
849
+ + "body{margin:0;font-family:'Segoe UI','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text);line-height:1.6;padding:16px;transition:background 0.2s,color 0.2s;}"
850
+ ".container{width:100%;max-width:none;margin:0 auto;}"
851
+ ".section{margin-bottom:24px;}"
852
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;padding:18px 20px;box-shadow:0 2px 8px var(--shadow);margin-bottom:18px;}"
853
+ ".card-header{display:flex;align-items:center;gap:12px;margin-bottom:12px;}"
854
+ ".result-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--accent-soft);color:var(--text);padding:6px 10px;border-radius:999px;font-weight:700;font-size:14px;}"
855
+ ".kv{display:grid;grid-template-columns:200px minmax(0,1fr);gap:8px 14px;padding:8px 0;border-bottom:1px solid var(--card-border);}"
856
+ ".kv:last-child{border-bottom:none;}"
857
+ ".k{font-weight:600;color:var(--text);}"
858
+ ".v{color:var(--text);}"
859
+ ".chip{background:var(--accent-soft);color:var(--text);padding:2px 8px;border-radius:999px;font-weight:600;font-size:12px;margin-left:8px;}"
860
+ ".stage-card{border:1px solid var(--card-border);background:var(--bg);border-radius:10px;padding:12px 14px;margin-bottom:12px;}"
861
+ ".stage-header{display:flex;align-items:center;gap:10px;margin-bottom:8px;}"
862
+ ".stage-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--badge-bg);color:var(--text);font-weight:700;border-radius:8px;padding:4px 8px;min-width:32px;}"
863
+ ".stage-title{font-weight:700;color:var(--text);font-size:15px;}"
864
+ ".stage-subsection{margin-top:10px;}"
865
+ ".section-header{font-weight:700;color:var(--text);background:var(--card-border);padding:8px 12px;border-radius:6px;margin:10px 0 6px 0;font-size:13px;}"
866
+ ".list-item{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;margin:8px 0;}"
867
+ ".nested-card{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;margin-top:6px;}"
868
+ ".text{background:var(--card-border);border-radius:6px;padding:4px 8px;display:inline-block;}"
869
+ ".muted{color:var(--muted);font-style:italic;}"
870
+ ".html-preview{margin-top:6px;border:1px solid var(--card-border);border-radius:8px;overflow:hidden;}"
871
+ ".html-preview-label{background:var(--pre-bg);color:var(--pre-text);padding:6px 10px;font-weight:700;font-size:12px;}"
872
+ ".html-iframe{width:100%;min-height:240px;border:0;display:block;}"
873
+ ".html-raw{margin:0;padding:10px;}"
874
+ ".pre-inline{background:var(--pre-bg);color:var(--pre-text);padding:8px;border-radius:8px;overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
875
+ )
876
+
877
+ return (
878
+ "<!doctype html>"
879
+ "<html><head><meta charset='utf-8'>"
880
+ "<title>FuseSell AI - Query Results</title>"
881
+ f"<style>{style}</style>"
882
+ "</head><body>"
883
+ "<div class='container'>"
884
+ "<h1>FuseSell AI - Query Results</h1>"
885
+ + ("<div class='section'><h3>Filters</h3>" + ("".join(filter_rows) or "<div class='muted'>No filters.</div>") + "</div>" if filter_rows else "")
886
+ + ("<div class='section'><h2>Results</h2>" + ("".join(cards) or "<div class='muted'>No results.</div>") + "</div>")
887
+ + "<details><summary>View Raw JSON</summary>"
888
+ "<pre class='pre-inline'>"
889
+ f"{raw_escaped}"
890
+ "</pre></details>"
891
+ "</div>"
892
+ "<script>"
893
+ "function applyTheme(theme){"
894
+ "document.body.classList.remove('light','dark');"
895
+ "document.body.classList.add(theme);"
896
+ "}"
897
+ "window.parent.postMessage({type:'get-theme'},'*');"
898
+ "window.addEventListener('message',(event)=>{"
899
+ "if(event.data?.type==='theme-response'||event.data?.type==='theme-change'){"
900
+ "applyTheme(event.data.theme);"
901
+ "}"
902
+ "});"
903
+ "setTimeout(()=>{"
904
+ "if(!document.body.classList.contains('light')&&!document.body.classList.contains('dark')){"
905
+ "applyTheme(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');"
906
+ "}"
907
+ "},100);"
908
+ "const resizeObserver=new ResizeObserver((entries)=>{"
909
+ "entries.forEach((entry)=>{"
910
+ "window.parent.postMessage({type:'ui-size-change',payload:{height:entry.contentRect.height}},'*');"
911
+ "});"
912
+ "});"
913
+ "resizeObserver.observe(document.documentElement);"
914
+ "</script>"
915
+ "</body></html>"
916
+ )
917
+
918
+
919
+ # =============================================================================
920
+ # Start Sales Process (fallback parity with flow script)
921
+ # =============================================================================
922
+
923
+ def _start_collect_customer_blob(payload: Dict[str, Any]) -> Dict[str, Any]:
924
+ stage_results = payload.get("stage_results") or {}
925
+ for candidate in (
926
+ payload.get("customer_data"),
927
+ payload.get("customer"),
928
+ stage_results.get("data_preparation", {}).get("data"),
929
+ stage_results.get("data_acquisition", {}).get("data"),
930
+ ):
931
+ if isinstance(candidate, dict):
932
+ return candidate
933
+ return {}
934
+
935
+
936
+ def _start_build_process_summary(payload: Dict[str, Any]) -> Dict[str, str]:
937
+ perf = payload.get("performance_analytics") or {}
938
+ insights = perf.get("performance_insights") or {}
939
+ stage_timings = perf.get("stage_timings") or []
940
+ return {
941
+ "execution_id": _first_non_empty(payload.get("execution_id")),
942
+ "status": _first_non_empty(payload.get("status")),
943
+ "started_at": _format_timestamp(payload.get("started_at")),
944
+ "duration_seconds": _format_number(payload.get("duration_seconds")),
945
+ "stage_count": _first_non_empty(perf.get("stage_count") or len(stage_timings)),
946
+ "avg_stage_duration": _format_number(perf.get("average_stage_duration")),
947
+ "pipeline_overhead": _format_percent(perf.get("pipeline_overhead_percentage")),
948
+ "slowest_stage": _first_non_empty((insights.get("slowest_stage") or {}).get("name")),
949
+ "fastest_stage": _first_non_empty((insights.get("fastest_stage") or {}).get("name")),
950
+ }
951
+
952
+
953
+ def _start_build_stage_rows(payload: Dict[str, Any]) -> List[Dict[str, str]]:
954
+ rows: List[Dict[str, str]] = []
955
+ perf = payload.get("performance_analytics") or {}
956
+ stage_timings = perf.get("stage_timings") or []
957
+ total_duration = perf.get("total_stage_duration_seconds") or perf.get("total_duration_seconds")
958
+
959
+ if isinstance(stage_timings, list) and stage_timings:
960
+ for timing in stage_timings:
961
+ if not isinstance(timing, dict):
962
+ continue
963
+ duration = timing.get("duration_seconds")
964
+ percent = timing.get("percentage_of_total")
965
+ if percent is None and duration and total_duration:
966
+ try:
967
+ percent = (float(duration) / float(total_duration)) * 100
968
+ except Exception:
969
+ percent = None
970
+ rows.append(
971
+ {
972
+ "stage": _first_non_empty(timing.get("stage")),
973
+ "duration": _format_number(duration),
974
+ "percent": _format_percent(percent),
975
+ "start": _format_timestamp(timing.get("start_time")),
976
+ "end": _format_timestamp(timing.get("end_time")),
977
+ }
978
+ )
979
+ else:
980
+ stage_results = payload.get("stage_results")
981
+ if isinstance(stage_results, dict):
982
+ durations: List[float] = []
983
+ for stage_payload in stage_results.values():
984
+ timing = stage_payload.get("timing") if isinstance(stage_payload, dict) else None
985
+ if isinstance(timing, dict):
986
+ durations.append(timing.get("duration_seconds") or 0)
987
+ total = float(sum(durations)) if durations else None
988
+ for stage_name, stage_payload in stage_results.items():
989
+ timing = stage_payload.get("timing") if isinstance(stage_payload, dict) else None
990
+ duration = timing.get("duration_seconds") if isinstance(timing, dict) else None
991
+ percent = None
992
+ if duration and total:
993
+ try:
994
+ percent = (float(duration) / total) * 100
995
+ except Exception:
996
+ percent = None
997
+ rows.append(
998
+ {
999
+ "stage": _friendly_key(stage_name),
1000
+ "duration": _format_number(duration),
1001
+ "percent": _format_percent(percent),
1002
+ "start": _format_timestamp(timing.get("start_time") if isinstance(timing, dict) else None),
1003
+ "end": _format_timestamp(timing.get("end_time") if isinstance(timing, dict) else None),
1004
+ }
1005
+ )
1006
+ return rows
1007
+
1008
+
1009
+ def _start_build_customer_info(payload: Dict[str, Any]) -> Dict[str, str]:
1010
+ blob = _start_collect_customer_blob(payload)
1011
+ company = blob.get("companyInfo") if isinstance(blob, dict) else {}
1012
+ primary = blob.get("primaryContact") if isinstance(blob, dict) else {}
1013
+ financial = blob.get("financialInfo") if isinstance(blob, dict) else {}
1014
+ health = financial.get("healthAssessment") if isinstance(financial, dict) else {}
1015
+
1016
+ revenue = ""
1017
+ revenue_history = financial.get("revenueLastThreeYears") if isinstance(financial, dict) else None
1018
+ if isinstance(revenue_history, list) and revenue_history:
1019
+ parts = []
1020
+ for entry in revenue_history:
1021
+ if not isinstance(entry, dict):
1022
+ continue
1023
+ year = entry.get("year")
1024
+ rev = entry.get("revenue")
1025
+ if year is None and rev is None:
1026
+ continue
1027
+ parts.append(f"{year}: {_format_number(rev)}")
1028
+ revenue = ", ".join(parts)
1029
+
1030
+ recommendations = ""
1031
+ recs = health.get("recommendations") if isinstance(health, dict) else None
1032
+ if isinstance(recs, list) and recs:
1033
+ recommendations = "; ".join(str(item) for item in recs if item is not None)
1034
+
1035
+ funding_sources = ""
1036
+ sources = financial.get("fundingSources") if isinstance(financial, dict) else None
1037
+ if isinstance(sources, list) and sources:
1038
+ funding_sources = ", ".join(str(item) for item in sources if item is not None)
1039
+ elif sources:
1040
+ funding_sources = str(sources)
1041
+
1042
+ industries = blob.get("company_industries")
1043
+ industry = (
1044
+ _first_non_empty(company.get("industry") if isinstance(company, dict) else None)
1045
+ or (", ".join(industries) if isinstance(industries, list) and industries else "")
1046
+ )
1047
+
1048
+ return {
1049
+ "company_name": _first_non_empty(company.get("name") if isinstance(company, dict) else None, blob.get("company_name")),
1050
+ "industry": industry,
1051
+ "website": _first_non_empty(company.get("website") if isinstance(company, dict) else None, blob.get("company_website")),
1052
+ "contact_name": _first_non_empty(
1053
+ primary.get("name") if isinstance(primary, dict) else None,
1054
+ blob.get("contact_name"),
1055
+ blob.get("customer_name"),
1056
+ ),
1057
+ "contact_email": _first_non_empty(
1058
+ primary.get("email") if isinstance(primary, dict) else None,
1059
+ blob.get("customer_email"),
1060
+ blob.get("recipient_address"),
1061
+ ),
1062
+ "funding_sources": funding_sources,
1063
+ "revenue_last_three_years": revenue,
1064
+ "overall_rating": _first_non_empty(health.get("overallRating") if isinstance(health, dict) else None),
1065
+ "recommendations": recommendations,
1066
+ }
1067
+
1068
+
1069
+ def _start_build_pain_points(payload: Dict[str, Any]) -> List[Dict[str, str]]:
1070
+ blob = _start_collect_customer_blob(payload)
1071
+ pain_points = blob.get("painPoints") if isinstance(blob, dict) else None
1072
+ rows: List[Dict[str, str]] = []
1073
+ if isinstance(pain_points, list):
1074
+ for point in pain_points:
1075
+ if not isinstance(point, dict):
1076
+ continue
1077
+ rows.append(
1078
+ {
1079
+ "category": _first_non_empty(point.get("category")),
1080
+ "description": _first_non_empty(point.get("description")),
1081
+ "impact": _first_non_empty(point.get("impact")),
1082
+ }
1083
+ )
1084
+ return rows
1085
+
1086
+
1087
+ def _start_build_tech_stack(payload: Dict[str, Any]) -> List[str]:
1088
+ blob = _start_collect_customer_blob(payload)
1089
+ tech_and_innovation = blob.get("technologyAndInnovation") if isinstance(blob, dict) else {}
1090
+ stacks: List[str] = []
1091
+ for candidate in (
1092
+ blob.get("currentTechStack"),
1093
+ tech_and_innovation.get("likelyTechStack") if isinstance(tech_and_innovation, dict) else None,
1094
+ tech_and_innovation.get("recommendedTechnologies") if isinstance(tech_and_innovation, dict) else None,
1095
+ ):
1096
+ if isinstance(candidate, list):
1097
+ stacks.extend(str(item) for item in candidate if item is not None)
1098
+ seen = set()
1099
+ deduped: List[str] = []
1100
+ for item in stacks:
1101
+ if item not in seen:
1102
+ seen.add(item)
1103
+ deduped.append(item)
1104
+ return deduped
1105
+
1106
+
1107
+ def _start_build_innovation_points(payload: Dict[str, Any]) -> List[str]:
1108
+ blob = _start_collect_customer_blob(payload)
1109
+ tech_and_innovation = blob.get("technologyAndInnovation") if isinstance(blob, dict) else {}
1110
+ points: List[str] = []
1111
+ for candidate in (
1112
+ tech_and_innovation.get("technologyGaps") if isinstance(tech_and_innovation, dict) else None,
1113
+ tech_and_innovation.get("innovationOpportunities") if isinstance(tech_and_innovation, dict) else None,
1114
+ ):
1115
+ if isinstance(candidate, list):
1116
+ points.extend(str(item) for item in candidate if item is not None)
1117
+ return points
1118
+
1119
+
1120
+ def _start_build_lead_fit(payload: Dict[str, Any]) -> Dict[str, str]:
1121
+ stage_results = payload.get("stage_results") or {}
1122
+ lead_stage = stage_results.get("lead_scoring", {}) if isinstance(stage_results, dict) else {}
1123
+ lead_data = lead_stage.get("data") if isinstance(lead_stage, dict) else {}
1124
+ lead_scores = []
1125
+ if isinstance(lead_data, dict):
1126
+ scores = lead_data.get("lead_scoring")
1127
+ if isinstance(scores, list) and scores:
1128
+ lead_scores = scores
1129
+ lead_entry = lead_scores[0] if lead_scores else {}
1130
+ analysis = lead_data.get("analysis") if isinstance(lead_data, dict) else {}
1131
+ recommended = analysis.get("recommended_product") if isinstance(analysis, dict) else {}
1132
+ scores = lead_entry.get("scores") if isinstance(lead_entry, dict) else {}
1133
+ return {
1134
+ "product_name": _first_non_empty(
1135
+ lead_entry.get("product_name"),
1136
+ recommended.get("product_name") if isinstance(recommended, dict) else None,
1137
+ ),
1138
+ "industry_fit": _format_number((scores.get("industry_fit") or {}).get("score") if isinstance(scores, dict) else None),
1139
+ "pain_points_addressed": _format_number((scores.get("pain_points") or {}).get("score") if isinstance(scores, dict) else None),
1140
+ "geographic_market_fit": _format_number((scores.get("geographic_market_fit") or {}).get("score") if isinstance(scores, dict) else None),
1141
+ "total_weighted_score": _format_number(lead_entry.get("total_weighted_score") if isinstance(lead_entry, dict) else None),
1142
+ "recommendation": _first_non_empty(
1143
+ (analysis.get("insights") or [None])[0] if isinstance(analysis, dict) and isinstance(analysis.get("insights"), list) else None
1144
+ ),
1145
+ }
1146
+
1147
+
1148
+ def _start_filter_primary_drafts(drafts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1149
+ """Return only priority_order 1 drafts; fallback to the first draft."""
1150
+ if not drafts:
1151
+ return drafts
1152
+
1153
+ primary: List[Dict[str, Any]] = []
1154
+ for draft in drafts:
1155
+ if not isinstance(draft, dict):
1156
+ continue
1157
+ try:
1158
+ priority_val = int(draft.get("priority_order"))
1159
+ except (TypeError, ValueError):
1160
+ priority_val = None
1161
+ if priority_val == 1:
1162
+ primary.append(draft)
1163
+
1164
+ if primary:
1165
+ return primary
1166
+ return drafts[:1]
1167
+
1168
+
1169
+ def _start_collect_email_drafts(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1170
+ drafts: List[Dict[str, Any]] = []
1171
+ seen_ids = set()
1172
+
1173
+ for candidate in (
1174
+ payload.get("email_drafts"),
1175
+ payload.get("stage_results", {}).get("initial_outreach", {}).get("data", {}).get("email_drafts"),
1176
+ ):
1177
+ if not isinstance(candidate, list):
1178
+ continue
1179
+ for item in candidate:
1180
+ if not isinstance(item, dict):
1181
+ continue
1182
+ dedupe_key = item.get("draft_id") or item.get("id") or id(item)
1183
+ if dedupe_key in seen_ids:
1184
+ continue
1185
+ seen_ids.add(dedupe_key)
1186
+ drafts.append(item)
1187
+ return drafts
1188
+
1189
+
1190
+ def _start_extract_reminder_time(payload: Dict[str, Any]) -> Optional[str]:
1191
+ """Extract reminder scheduled_time from payload or stage results."""
1192
+ if not isinstance(payload, dict):
1193
+ return None
1194
+
1195
+ stage_results = payload.get("stage_results", {})
1196
+ reminder_candidates = [payload.get("reminder_schedule")]
1197
+ if isinstance(stage_results, dict):
1198
+ initial_outreach = stage_results.get("initial_outreach", {})
1199
+ if isinstance(initial_outreach, dict):
1200
+ reminder_candidates.append(initial_outreach.get("reminder_schedule"))
1201
+ io_data = initial_outreach.get("data", {})
1202
+ if isinstance(io_data, dict):
1203
+ reminder_candidates.append(io_data.get("reminder_schedule"))
1204
+
1205
+ for candidate in reminder_candidates:
1206
+ if isinstance(candidate, dict):
1207
+ scheduled = candidate.get("scheduled_time")
1208
+ if scheduled:
1209
+ return str(scheduled)
1210
+ return None
1211
+
1212
+
1213
+ def _start_format_reminder_time(value: Any) -> str:
1214
+ """Normalize reminder time into a local time string (HH:MM DD/MM/YYYY)."""
1215
+ return _format_timestamp(value)
1216
+
1217
+
1218
+ def _render_start_email_drafts(drafts: List[Dict[str, Any]], reminder_time: Optional[str] = None) -> str:
1219
+ """Render drafts in the compact start-sales style."""
1220
+ parts: List[str] = []
1221
+ formatted_reminder = _start_format_reminder_time(reminder_time)
1222
+ if formatted_reminder:
1223
+ parts.append(
1224
+ "<div class='chips'>"
1225
+ f"<span class='chip chip-soft'>Reminder scheduled: {html.escape(formatted_reminder)}</span>"
1226
+ "</div>"
1227
+ )
1228
+ if not drafts:
1229
+ parts.append("<div class='muted'>No email drafts generated.</div>")
1230
+ return "".join(parts)
1231
+ for idx, draft in enumerate(drafts, start=1):
1232
+ subject = draft.get("subject") or f"Draft {idx}"
1233
+ priority = _first_non_empty(draft.get("priority_order"), (draft.get("metadata") or {}).get("priority_order"))
1234
+ recipient = _first_non_empty(
1235
+ draft.get("recipient_name"),
1236
+ draft.get("recipient_email"),
1237
+ (draft.get("metadata") or {}).get("recipient_name"),
1238
+ )
1239
+ chips = []
1240
+ if priority:
1241
+ chips.append(f"<span class='chip chip-soft'>Priority {html.escape(str(priority))}</span>")
1242
+ if recipient:
1243
+ chips.append(f"<span class='chip chip-soft'>{html.escape(str(recipient))}</span>")
1244
+ body_html = draft.get("email_body") or draft.get("body_html") or draft.get("html_body") or ""
1245
+
1246
+ # Extract body content if the email_body is a full HTML document
1247
+ body_content = body_html
1248
+ if body_html.strip().lower().startswith(('<!doctype', '<html')):
1249
+ # Extract content between <body> tags if present
1250
+ body_match = re.search(r'<body[^>]*>(.*?)</body>', body_html, re.DOTALL | re.IGNORECASE)
1251
+ if body_match:
1252
+ body_content = body_match.group(1)
1253
+
1254
+ iframe_doc = (
1255
+ "<!doctype html>"
1256
+ "<html><head><meta charset='utf-8'>"
1257
+ "<style>"
1258
+ "body{margin:0;padding:16px;font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;color:#0f172a;"
1259
+ "line-height:1.6;background:#ffffff;}"
1260
+ "h1,h2,h3,h4,h5{margin:0 0 10px 0;color:#0f172a;}"
1261
+ "p{margin:0 0 12px 0;}"
1262
+ "ul{padding-left:20px;margin:0 0 12px 0;}"
1263
+ "li{margin:6px 0;}"
1264
+ "strong{font-weight:700;}"
1265
+ "</style>"
1266
+ "</head><body>"
1267
+ f"{body_content}"
1268
+ "</body></html>"
1269
+ )
1270
+ encoded_doc = base64.b64encode(iframe_doc.encode("utf-8")).decode("ascii")
1271
+ iframe = (
1272
+ "<div class='html-preview start-preview'>"
1273
+ "<div class='html-preview-label start-label'>Rendered HTML</div>"
1274
+ f"<iframe class='html-iframe' sandbox src=\"data:text/html;charset=utf-8;base64,{encoded_doc}\" loading='lazy'></iframe>"
1275
+ "</div>"
1276
+ )
1277
+ parts.append(
1278
+ "<div class='draft start-draft'>"
1279
+ "<h3>Email Draft</h3>"
1280
+ f"<h4>{html.escape(subject)}</h4>"
1281
+ + ("<div class='chips'>" + "".join(chips) + "</div>" if chips else "")
1282
+ + iframe
1283
+ + "</div>"
1284
+ )
1285
+ return "".join(parts)
1286
+
1287
+
1288
+ def _render_start_sales_process_html(payload: Dict[str, Any], raw_json: str) -> str:
1289
+ summary = _start_build_process_summary(payload)
1290
+ stages = _start_build_stage_rows(payload)
1291
+ customer = _start_build_customer_info(payload)
1292
+ pains = _start_build_pain_points(payload)
1293
+ tech_stack = _start_build_tech_stack(payload)
1294
+ innovations = _start_build_innovation_points(payload)
1295
+ lead_fit = _start_build_lead_fit(payload)
1296
+ drafts = _start_collect_email_drafts(payload)
1297
+ drafts = _start_filter_primary_drafts(drafts)
1298
+ reminder_time = _start_extract_reminder_time(payload)
1299
+
1300
+ def _row(label: str, value: str) -> str:
1301
+ return f"<tr><th>{html.escape(label)}</th><td>{html.escape(value) if value else ''}</td></tr>"
1302
+
1303
+ stage_rows_html = "".join(
1304
+ "<tr>"
1305
+ f"<td>{html.escape(row.get('stage', ''))}</td>"
1306
+ f"<td>{html.escape(row.get('duration', ''))}</td>"
1307
+ f"<td>{html.escape(row.get('percent', ''))}</td>"
1308
+ f"<td>{html.escape(row.get('start', ''))}</td>"
1309
+ f"<td>{html.escape(row.get('end', ''))}</td>"
1310
+ "</tr>"
1311
+ for row in stages
1312
+ )
1313
+
1314
+ pain_rows_html = "".join(
1315
+ "<tr>"
1316
+ f"<td>{html.escape(pain.get('category', ''))}</td>"
1317
+ f"<td>{html.escape(pain.get('description', ''))}</td>"
1318
+ f"<td>{html.escape(pain.get('impact', ''))}</td>"
1319
+ "</tr>"
1320
+ for pain in pains
1321
+ )
1322
+
1323
+ tech_chips = "".join(f"<span class='chip'>{html.escape(item)}</span>" for item in tech_stack)
1324
+ innovation_list = "".join(f"<li>{html.escape(item)}</li>" for item in innovations)
1325
+
1326
+ draft_html = _render_start_email_drafts(drafts, reminder_time)
1327
+
1328
+ raw_escaped = html.escape(raw_json)
1329
+ style = (
1330
+ CSS_THEME_VARS
1331
+ + "html,body{margin:0;padding:0;}"
1332
+ "body{font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.6;transition:background 0.2s,color 0.2s;}"
1333
+ "*{box-sizing:border-box;}"
1334
+ ".container{max-width:1200px;margin:40px auto;background:var(--card-bg);border-radius:10px;box-shadow:0 4px 16px var(--shadow-dark);padding:40px 36px;}"
1335
+ "h1,h2,h3,h4{color:var(--muted);}"
1336
+ ".section{margin-bottom:36px;}"
1337
+ "table{border-collapse:collapse;width:100%;margin-bottom:24px;table-layout:fixed;}"
1338
+ "th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--card-border);word-break:break-word;}"
1339
+ "th{background:var(--card-border);font-weight:600;width:260px;}"
1340
+ ".stage-table th,.stage-table td{width:auto;padding:8px;}"
1341
+ ".badge{display:inline-block;background:var(--accent-soft);color:var(--text);padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px;}"
1342
+ "details{margin-top:16px;}"
1343
+ "summary{cursor:pointer;color:var(--accent);font-weight:600;}"
1344
+ "pre{background:var(--card-border);border-radius:8px;padding:12px;font-size:13px;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;overflow-x:auto;}"
1345
+ "ul{padding-left:18px;}"
1346
+ "li{margin:6px 0;}"
1347
+ ".chips{display:flex;gap:10px;flex-wrap:wrap;margin:4px 0;}"
1348
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 14px;border-radius:999px;font-weight:600;font-size:12px;}"
1349
+ ".chip-soft{background:var(--card-border);color:var(--text);}"
1350
+ ".iframe-draft{width:100%;min-height:340px;border:1px solid var(--card-border);border-radius:8px;margin-bottom:16px;}"
1351
+ ".draft{margin-bottom:20px;padding:16px;background:var(--bg);border:1px solid var(--card-border);border-radius:8px;}"
1352
+ ".start-draft{background:var(--bg);border:1px solid var(--card-border);}"
1353
+ ".start-preview{margin-top:10px;border:1px solid var(--card-border);border-radius:10px;overflow:hidden;background:var(--card-bg);}"
1354
+ ".start-label{background:var(--pre-bg);color:var(--pre-text);padding:10px;font-weight:700;}"
1355
+ ".draft-meta{font-size:13px;color:var(--muted);margin-bottom:8px;}"
1356
+ ".kv{display:grid;grid-template-columns:240px minmax(0,1fr);gap:8px 14px;padding:10px 0;border-bottom:1px solid var(--card-border);align-items:flex-start;}"
1357
+ ".kv:last-child{border-bottom:none;}"
1358
+ ".k{font-weight:600;color:var(--text);background:var(--card-border);border-radius:6px;padding:8px 10px;}"
1359
+ ".v{color:var(--text);min-width:0;}"
1360
+ ".text{background:var(--card-border);border-radius:6px;padding:8px 10px;display:block;width:100%;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;}"
1361
+ ".nested-card{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;}"
1362
+ ".pre-inline{background:var(--card-border);color:var(--text);padding:10px;border-radius:6px;overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;}"
1363
+ ".html-preview{display:flex;flex-direction:column;gap:8px;}"
1364
+ ".html-preview-label{font-weight:600;color:var(--text);font-size:13px;}"
1365
+ ".html-iframe{width:100%;min-height:340px;border:0;display:block;}"
1366
+ ".html-raw summary{cursor:pointer;color:var(--accent);font-weight:600;}"
1367
+ "@media (max-width: 900px){.kv{grid-template-columns:minmax(140px,1fr) minmax(0,2fr);padding:8px 10px;}}"
1368
+ )
1369
+
1370
+ return (
1371
+ "<!doctype html>"
1372
+ "<html><head><meta charset='utf-8'>"
1373
+ "<title>FuseSell AI - Sales Process Report</title>"
1374
+ f"<style>{style}</style>"
1375
+ "</head><body>"
1376
+ "<div class='container'>"
1377
+ "<h1>FuseSell AI - Sales Process Report</h1>"
1378
+ "<div class='section'>"
1379
+ "<h2>Process Summary</h2>"
1380
+ "<table>"
1381
+ f"{_row('Execution Id', summary.get('execution_id', ''))}"
1382
+ f"{_row('Status', summary.get('status', ''))}"
1383
+ f"{_row('Started At', summary.get('started_at', ''))}"
1384
+ f"{_row('Duration Seconds', summary.get('duration_seconds', ''))}"
1385
+ f"{_row('Stage Count', summary.get('stage_count', ''))}"
1386
+ f"{_row('Avg. Stage Duration', summary.get('avg_stage_duration', ''))}"
1387
+ f"{_row('Pipeline Overhead (%)', summary.get('pipeline_overhead', ''))}"
1388
+ f"{_row('Slowest Stage', summary.get('slowest_stage', ''))}"
1389
+ f"{_row('Fastest Stage', summary.get('fastest_stage', ''))}"
1390
+ "</table>"
1391
+ "</div>"
1392
+ "<div class='section'>"
1393
+ "<h2>Stage Results</h2>"
1394
+ "<table class='stage-table'>"
1395
+ "<tr><th>Stage</th><th>Duration (s)</th><th>% of Total</th><th>Start Time</th><th>End Time</th></tr>"
1396
+ f"{stage_rows_html or '<tr><td colspan=\"5\">No stage timings available.</td></tr>'}"
1397
+ "</table>"
1398
+ "</div>"
1399
+ "<div class='section'>"
1400
+ "<h2>Customer & Company Info</h2>"
1401
+ "<table>"
1402
+ f"{_row('Company Name', customer.get('company_name', ''))}"
1403
+ f"{_row('Industry', customer.get('industry', ''))}"
1404
+ f"{_row('Website', customer.get('website', ''))}"
1405
+ f"{_row('Contact Name', customer.get('contact_name', ''))}"
1406
+ f"{_row('Contact Email', customer.get('contact_email', ''))}"
1407
+ f"{_row('Funding Sources', customer.get('funding_sources', ''))}"
1408
+ f"{_row('Revenue Last 3 Years', customer.get('revenue_last_three_years', ''))}"
1409
+ f"{_row('Overall Rating', customer.get('overall_rating', ''))}"
1410
+ f"{_row('Recommendations', customer.get('recommendations', ''))}"
1411
+ "</table>"
1412
+ "</div>"
1413
+ "<div class='section'>"
1414
+ "<h2>Pain Points</h2>"
1415
+ "<table>"
1416
+ "<tr><th>Category</th><th>Description</th><th>Impact</th></tr>"
1417
+ f"{pain_rows_html or '<tr><td colspan=\"3\">No pain points captured.</td></tr>'}"
1418
+ "</table>"
1419
+ "</div>"
1420
+ "<div class='section'>"
1421
+ "<h2>Tech Stack & Innovation</h2>"
1422
+ f"<div class='chips'>{tech_chips or '<span class=\"chip\">Not specified</span>'}</div>"
1423
+ "<h4>Innovation Gaps & Opportunities</h4>"
1424
+ f"<ul>{innovation_list or '<li>No innovation insights captured.</li>'}</ul>"
1425
+ "</div>"
1426
+ "<div class='section'>"
1427
+ "<h2>Lead Scoring - Product Fit</h2>"
1428
+ "<table>"
1429
+ f"{_row('Product Name', lead_fit.get('product_name', ''))}"
1430
+ f"{_row('Industry Fit', lead_fit.get('industry_fit', ''))}"
1431
+ f"{_row('Pain Points Addressed', lead_fit.get('pain_points_addressed', ''))}"
1432
+ f"{_row('Geographic Market Fit', lead_fit.get('geographic_market_fit', ''))}"
1433
+ f"{_row('Total Weighted Score', lead_fit.get('total_weighted_score', ''))}"
1434
+ f"{_row('Recommendation', lead_fit.get('recommendation', ''))}"
1435
+ "</table>"
1436
+ "</div>"
1437
+ "<div class='section'>"
1438
+ "<h2>Email Outreach Drafts</h2>"
1439
+ "<details open>"
1440
+ "<summary>Show All Drafts</summary>"
1441
+ f"{draft_html}"
1442
+ "</details>"
1443
+ "</div>"
1444
+ "<div class='section'>"
1445
+ "<h2>Raw JSON</h2>"
1446
+ "<details>"
1447
+ "<summary>View Full Raw JSON</summary>"
1448
+ "<pre>"
1449
+ f"{raw_escaped}"
1450
+ "</pre>"
1451
+ "</details>"
1452
+ "</div>"
1453
+ "</div>"
1454
+ "</body></html>"
1455
+ )
1456
+
1457
+
1458
+ def _collect_customer_blob(payload: Dict[str, Any]) -> Dict[str, Any]:
1459
+ """Extract customer data from various locations in the payload."""
1460
+ stage_results = payload.get("stage_results") or {}
1461
+ for candidate in (
1462
+ payload.get("customer_data"),
1463
+ payload.get("customer"),
1464
+ stage_results.get("data_preparation", {}).get("data"),
1465
+ stage_results.get("data_acquisition", {}).get("data"),
1466
+ ):
1467
+ if isinstance(candidate, dict):
1468
+ return candidate
1469
+ return {}
1470
+
1471
+
1472
+ def _build_process_summary(payload: Dict[str, Any]) -> Dict[str, str]:
1473
+ """Build process summary from payload."""
1474
+ perf = payload.get("performance_analytics") or {}
1475
+ insights = perf.get("performance_insights") or {}
1476
+ stage_timings = perf.get("stage_timings") or []
1477
+ return {
1478
+ "execution_id": _first_non_empty(payload.get("execution_id")),
1479
+ "status": _first_non_empty(payload.get("status")),
1480
+ "started_at": _first_non_empty(payload.get("started_at")),
1481
+ "duration_seconds": _format_number(payload.get("duration_seconds")),
1482
+ "stage_count": _first_non_empty(perf.get("stage_count") or len(stage_timings)),
1483
+ "avg_stage_duration": _format_number(perf.get("average_stage_duration")),
1484
+ "pipeline_overhead": _format_percent(perf.get("pipeline_overhead_percentage")),
1485
+ "slowest_stage": _first_non_empty((insights.get("slowest_stage") or {}).get("name")),
1486
+ "fastest_stage": _first_non_empty((insights.get("fastest_stage") or {}).get("name")),
1487
+ }
1488
+
1489
+
1490
+ def _build_stage_rows(payload: Dict[str, Any]) -> List[Dict[str, str]]:
1491
+ """Build stage timing rows from payload."""
1492
+ rows: List[Dict[str, str]] = []
1493
+ perf = payload.get("performance_analytics") or {}
1494
+ stage_timings = perf.get("stage_timings") or []
1495
+ total_duration = perf.get("total_stage_duration_seconds") or perf.get("total_duration_seconds")
1496
+
1497
+ if isinstance(stage_timings, list) and stage_timings:
1498
+ for timing in stage_timings:
1499
+ if not isinstance(timing, dict):
1500
+ continue
1501
+ duration = timing.get("duration_seconds")
1502
+ percent = timing.get("percentage_of_total")
1503
+ if percent is None and duration and total_duration:
1504
+ try:
1505
+ percent = (float(duration) / float(total_duration)) * 100
1506
+ except Exception:
1507
+ percent = None
1508
+ rows.append(
1509
+ {
1510
+ "stage": _first_non_empty(timing.get("stage")),
1511
+ "duration": _format_number(duration),
1512
+ "percent": _format_percent(percent),
1513
+ "start": _format_timestamp(timing.get("start_time")),
1514
+ "end": _format_timestamp(timing.get("end_time")),
1515
+ }
1516
+ )
1517
+ else:
1518
+ stage_results = payload.get("stage_results")
1519
+ if isinstance(stage_results, dict):
1520
+ durations: List[float] = []
1521
+ for stage_payload in stage_results.values():
1522
+ timing = stage_payload.get("timing") if isinstance(stage_payload, dict) else None
1523
+ if isinstance(timing, dict):
1524
+ durations.append(timing.get("duration_seconds") or 0)
1525
+ total = float(sum(durations)) if durations else None
1526
+ for stage_name, stage_payload in stage_results.items():
1527
+ timing = stage_payload.get("timing") if isinstance(stage_payload, dict) else None
1528
+ duration = timing.get("duration_seconds") if isinstance(timing, dict) else None
1529
+ percent = None
1530
+ if duration and total:
1531
+ try:
1532
+ percent = (float(duration) / total) * 100
1533
+ except Exception:
1534
+ percent = None
1535
+ rows.append(
1536
+ {
1537
+ "stage": _friendly_key(stage_name),
1538
+ "duration": _format_number(duration),
1539
+ "percent": _format_percent(percent),
1540
+ "start": _format_timestamp(timing.get("start_time") if isinstance(timing, dict) else None),
1541
+ "end": _format_timestamp(timing.get("end_time") if isinstance(timing, dict) else None),
1542
+ }
1543
+ )
1544
+ return rows
1545
+
1546
+
1547
+ def _build_customer_info(payload: Dict[str, Any]) -> Dict[str, str]:
1548
+ """Build customer info from payload."""
1549
+ blob = _collect_customer_blob(payload)
1550
+ company = blob.get("companyInfo") if isinstance(blob, dict) else {}
1551
+ primary = blob.get("primaryContact") if isinstance(blob, dict) else {}
1552
+ financial = blob.get("financialInfo") if isinstance(blob, dict) else {}
1553
+ health = financial.get("healthAssessment") if isinstance(financial, dict) else {}
1554
+
1555
+ revenue = ""
1556
+ revenue_history = financial.get("revenueLastThreeYears") if isinstance(financial, dict) else None
1557
+ if isinstance(revenue_history, list) and revenue_history:
1558
+ parts = []
1559
+ for entry in revenue_history:
1560
+ if not isinstance(entry, dict):
1561
+ continue
1562
+ year = entry.get("year")
1563
+ rev = entry.get("revenue")
1564
+ if year is None and rev is None:
1565
+ continue
1566
+ parts.append(f"{year}: {_format_number(rev)}")
1567
+ revenue = ", ".join(parts)
1568
+
1569
+ recommendations = ""
1570
+ recs = health.get("recommendations") if isinstance(health, dict) else None
1571
+ if isinstance(recs, list) and recs:
1572
+ recommendations = "; ".join(str(item) for item in recs if item is not None)
1573
+
1574
+ funding_sources = ""
1575
+ sources = financial.get("fundingSources") if isinstance(financial, dict) else None
1576
+ if isinstance(sources, list) and sources:
1577
+ funding_sources = ", ".join(str(item) for item in sources if item is not None)
1578
+ elif sources:
1579
+ funding_sources = str(sources)
1580
+
1581
+ industries = blob.get("company_industries")
1582
+ industry = (
1583
+ _first_non_empty(company.get("industry") if isinstance(company, dict) else None)
1584
+ or (", ".join(industries) if isinstance(industries, list) and industries else "")
1585
+ )
1586
+
1587
+ return {
1588
+ "company_name": _first_non_empty(company.get("name") if isinstance(company, dict) else None, blob.get("company_name")),
1589
+ "industry": industry,
1590
+ "website": _first_non_empty(company.get("website") if isinstance(company, dict) else None, blob.get("company_website")),
1591
+ "contact_name": _first_non_empty(
1592
+ primary.get("name") if isinstance(primary, dict) else None,
1593
+ blob.get("contact_name"),
1594
+ blob.get("customer_name"),
1595
+ ),
1596
+ "contact_email": _first_non_empty(
1597
+ primary.get("email") if isinstance(primary, dict) else None,
1598
+ blob.get("customer_email"),
1599
+ blob.get("recipient_address"),
1600
+ ),
1601
+ "funding_sources": funding_sources,
1602
+ "revenue_last_three_years": revenue,
1603
+ "overall_rating": _first_non_empty(health.get("overallRating") if isinstance(health, dict) else None),
1604
+ "recommendations": recommendations,
1605
+ }
1606
+
1607
+
1608
+ def _build_pain_points(payload: Dict[str, Any]) -> List[Dict[str, str]]:
1609
+ """Build pain points from payload."""
1610
+ blob = _collect_customer_blob(payload)
1611
+ pain_points = blob.get("painPoints") if isinstance(blob, dict) else None
1612
+ rows: List[Dict[str, str]] = []
1613
+ if isinstance(pain_points, list):
1614
+ for point in pain_points:
1615
+ if not isinstance(point, dict):
1616
+ continue
1617
+ rows.append(
1618
+ {
1619
+ "category": _first_non_empty(point.get("category")),
1620
+ "description": _first_non_empty(point.get("description")),
1621
+ "impact": _first_non_empty(point.get("impact")),
1622
+ }
1623
+ )
1624
+ return rows
1625
+
1626
+
1627
+ def _build_tech_stack(payload: Dict[str, Any]) -> List[str]:
1628
+ """Build tech stack from payload."""
1629
+ blob = _collect_customer_blob(payload)
1630
+ tech_and_innovation = blob.get("technologyAndInnovation") if isinstance(blob, dict) else {}
1631
+ stacks: List[str] = []
1632
+ for candidate in (
1633
+ blob.get("currentTechStack"),
1634
+ tech_and_innovation.get("likelyTechStack") if isinstance(tech_and_innovation, dict) else None,
1635
+ tech_and_innovation.get("recommendedTechnologies") if isinstance(tech_and_innovation, dict) else None,
1636
+ ):
1637
+ if isinstance(candidate, list):
1638
+ stacks.extend(str(item) for item in candidate if item is not None)
1639
+ seen = set()
1640
+ deduped: List[str] = []
1641
+ for item in stacks:
1642
+ if item not in seen:
1643
+ seen.add(item)
1644
+ deduped.append(item)
1645
+ return deduped
1646
+
1647
+
1648
+ def _build_innovation_points(payload: Dict[str, Any]) -> List[str]:
1649
+ """Build innovation points from payload."""
1650
+ blob = _collect_customer_blob(payload)
1651
+ tech_and_innovation = blob.get("technologyAndInnovation") if isinstance(blob, dict) else {}
1652
+ points: List[str] = []
1653
+ for candidate in (
1654
+ tech_and_innovation.get("technologyGaps") if isinstance(tech_and_innovation, dict) else None,
1655
+ tech_and_innovation.get("innovationOpportunities") if isinstance(tech_and_innovation, dict) else None,
1656
+ ):
1657
+ if isinstance(candidate, list):
1658
+ points.extend(str(item) for item in candidate if item is not None)
1659
+ return points
1660
+
1661
+
1662
+ def _build_lead_fit(payload: Dict[str, Any]) -> Dict[str, str]:
1663
+ """Build lead fit scores from payload."""
1664
+ stage_results = payload.get("stage_results") or {}
1665
+ lead_stage = stage_results.get("lead_scoring", {}) if isinstance(stage_results, dict) else {}
1666
+ lead_data = lead_stage.get("data") if isinstance(lead_stage, dict) else {}
1667
+ lead_scores = []
1668
+ if isinstance(lead_data, dict):
1669
+ scores = lead_data.get("lead_scoring")
1670
+ if isinstance(scores, list) and scores:
1671
+ lead_scores = scores
1672
+ lead_entry = lead_scores[0] if lead_scores else {}
1673
+ analysis = lead_data.get("analysis") if isinstance(lead_data, dict) else {}
1674
+ recommended = analysis.get("recommended_product") if isinstance(analysis, dict) else {}
1675
+ scores = lead_entry.get("scores") if isinstance(lead_entry, dict) else {}
1676
+ return {
1677
+ "product_name": _first_non_empty(
1678
+ lead_entry.get("product_name"),
1679
+ recommended.get("product_name") if isinstance(recommended, dict) else None,
1680
+ ),
1681
+ "industry_fit": _format_number((scores.get("industry_fit") or {}).get("score") if isinstance(scores, dict) else None),
1682
+ "pain_points_addressed": _format_number((scores.get("pain_points") or {}).get("score") if isinstance(scores, dict) else None),
1683
+ "geographic_market_fit": _format_number((scores.get("geographic_market_fit") or {}).get("score") if isinstance(scores, dict) else None),
1684
+ "total_weighted_score": _format_number(lead_entry.get("total_weighted_score") if isinstance(lead_entry, dict) else None),
1685
+ "recommendation": _first_non_empty(
1686
+ (analysis.get("insights") or [None])[0] if isinstance(analysis, dict) and isinstance(analysis.get("insights"), list) else None
1687
+ ),
1688
+ }
1689
+
1690
+
1691
+ def _collect_email_drafts(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1692
+ """Collect email drafts from payload."""
1693
+ drafts: List[Dict[str, Any]] = []
1694
+ for candidate in (
1695
+ payload.get("email_drafts"),
1696
+ payload.get("stage_results", {}).get("initial_outreach", {}).get("data", {}).get("email_drafts"),
1697
+ ):
1698
+ if isinstance(candidate, list):
1699
+ drafts.extend(item for item in candidate if isinstance(item, dict))
1700
+ return drafts
1701
+
1702
+
1703
+ def _render_sales_process_html(payload: Dict[str, Any], raw_json: str) -> str:
1704
+ """Render the start sales process HTML output."""
1705
+ summary = _build_process_summary(payload)
1706
+ stages = _build_stage_rows(payload)
1707
+ customer = _build_customer_info(payload)
1708
+ pains = _build_pain_points(payload)
1709
+ tech_stack = _build_tech_stack(payload)
1710
+ innovations = _build_innovation_points(payload)
1711
+ lead_fit = _build_lead_fit(payload)
1712
+ drafts = _collect_email_drafts(payload)
1713
+
1714
+ def _row(label: str, value: str) -> str:
1715
+ return f"<tr><th>{html.escape(label)}</th><td>{html.escape(value) if value else ''}</td></tr>"
1716
+
1717
+ stage_rows_html = "".join(
1718
+ "<tr>"
1719
+ f"<td>{html.escape(row.get('stage', ''))}</td>"
1720
+ f"<td>{html.escape(row.get('duration', ''))}</td>"
1721
+ f"<td>{html.escape(row.get('percent', ''))}</td>"
1722
+ f"<td>{html.escape(row.get('start', ''))}</td>"
1723
+ f"<td>{html.escape(row.get('end', ''))}</td>"
1724
+ "</tr>"
1725
+ for row in stages
1726
+ )
1727
+
1728
+ pain_rows_html = "".join(
1729
+ "<tr>"
1730
+ f"<td>{html.escape(pain.get('category', ''))}</td>"
1731
+ f"<td>{html.escape(pain.get('description', ''))}</td>"
1732
+ f"<td>{html.escape(pain.get('impact', ''))}</td>"
1733
+ "</tr>"
1734
+ for pain in pains
1735
+ )
1736
+
1737
+ tech_chips = "".join(f"<span class='chip'>{html.escape(item)}</span>" for item in tech_stack)
1738
+ innovation_list = "".join(f"<li>{html.escape(item)}</li>" for item in innovations)
1739
+
1740
+ draft_html_parts: List[str] = []
1741
+ for idx, draft in enumerate(drafts, start=1):
1742
+ subject = draft.get("subject") or f"Draft {idx}"
1743
+ recipient = _first_non_empty(
1744
+ draft.get("recipient_email"),
1745
+ draft.get("recipient_address"),
1746
+ draft.get("customer_email"),
1747
+ )
1748
+ approach = draft.get("approach")
1749
+ tone = draft.get("tone")
1750
+ call_to_action = draft.get("call_to_action")
1751
+ meta_chips = []
1752
+ for label in (approach, tone, recipient, draft.get("status")):
1753
+ if label:
1754
+ meta_chips.append(f"<span class='chip'>{html.escape(str(label))}</span>")
1755
+ meta = "".join(meta_chips)
1756
+ body_html = draft.get("email_body") or draft.get("body_html") or draft.get("html_body") or ""
1757
+ escaped_srcdoc = html.escape(body_html, quote=True)
1758
+ draft_html_parts.append(
1759
+ "<div class='draft'>"
1760
+ f"<h3>{html.escape(subject)}</h3>"
1761
+ + (f"<div class='chips'>{meta}</div>" if meta else "")
1762
+ + f"<iframe class='iframe-draft' sandbox srcdoc=\"{escaped_srcdoc}\" loading='lazy'></iframe>"
1763
+ + (
1764
+ f"<div class='draft-meta'><strong>Call to Action:</strong> {html.escape(call_to_action)}</div>"
1765
+ if call_to_action
1766
+ else ""
1767
+ )
1768
+ + "</div>"
1769
+ )
1770
+
1771
+ raw_escaped = html.escape(raw_json)
1772
+ style = (
1773
+ CSS_THEME_VARS
1774
+ + "html,body{margin:0;padding:0;}"
1775
+ "body{font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.6;transition:background 0.2s,color 0.2s;}"
1776
+ "*{box-sizing:border-box;}"
1777
+ ".container{max-width:1200px;margin:40px auto;background:var(--card-bg);border-radius:10px;box-shadow:0 4px 16px var(--shadow-dark);padding:40px 36px;}"
1778
+ "h1,h2,h3,h4{color:var(--muted);}"
1779
+ ".section{margin-bottom:36px;}"
1780
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;padding:18px 20px;box-shadow:0 2px 8px var(--shadow);margin-bottom:18px;}"
1781
+ ".card-header{display:flex;align-items:center;gap:12px;margin-bottom:12px;}"
1782
+ ".result-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--accent-soft);color:var(--text);padding:6px 10px;border-radius:999px;font-weight:700;font-size:14px;}"
1783
+ ".stage-card{border:1px solid var(--card-border);background:var(--bg);border-radius:10px;padding:12px 14px;margin-bottom:12px;}"
1784
+ ".stage-header{display:flex;align-items:center;gap:10px;margin-bottom:8px;}"
1785
+ ".stage-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--badge-bg);color:var(--text);font-weight:700;border-radius:8px;padding:4px 8px;min-width:32px;}"
1786
+ ".stage-title{font-weight:700;color:var(--text);font-size:15px;}"
1787
+ ".stage-subsection{margin-top:8px;}"
1788
+ ".kv{display:grid;grid-template-columns:200px minmax(0,1fr);gap:8px 14px;padding:8px 0;border-bottom:1px solid var(--card-border);}"
1789
+ ".kv:last-child{border-bottom:none;}"
1790
+ ".k{font-weight:600;color:var(--text);}"
1791
+ ".v{color:var(--text);}"
1792
+ ".section-header{font-weight:700;color:var(--text);background:var(--card-border);padding:8px 12px;border-radius:6px;margin:10px 0 6px 0;font-size:13px;}"
1793
+ ".list-item{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;margin:8px 0;}"
1794
+ ".nested-card{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;margin-top:6px;}"
1795
+ ".text{background:var(--card-border);border-radius:6px;padding:4px 8px;display:inline-block;}"
1796
+ ".html-preview{margin-top:6px;border:1px solid var(--card-border);border-radius:8px;overflow:hidden;}"
1797
+ ".html-preview-label{background:var(--pre-bg);color:var(--pre-text);padding:6px 10px;font-weight:700;font-size:12px;}"
1798
+ ".html-iframe{width:100%;min-height:240px;border:0;display:block;}"
1799
+ ".html-raw{margin:0;padding:10px;}"
1800
+ ".pre-inline{background:var(--pre-bg);color:var(--pre-text);padding:8px;border-radius:8px;overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
1801
+ ".muted{color:var(--muted);font-style:italic;}"
1802
+ "table{border-collapse:collapse;width:100%;margin-bottom:24px;table-layout:fixed;}"
1803
+ "th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--card-border);word-break:break-word;}"
1804
+ "th{background:var(--card-border);font-weight:600;width:260px;}"
1805
+ ".stage-table th,.stage-table td{width:auto;padding:8px;}"
1806
+ ".badge{display:inline-block;background:var(--accent-soft);color:var(--text);padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px;}"
1807
+ "details{margin-top:16px;}"
1808
+ "summary{cursor:pointer;color:var(--accent);font-weight:600;}"
1809
+ "pre{background:var(--card-border);border-radius:8px;padding:12px;font-size:13px;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;overflow-x:auto;}"
1810
+ "ul{padding-left:18px;}"
1811
+ "li{margin:6px 0;}"
1812
+ ".chips{display:flex;gap:10px;flex-wrap:wrap;margin:4px 0;}"
1813
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 14px;border-radius:999px;font-weight:600;font-size:12px;}"
1814
+ ".iframe-draft{width:100%;min-height:340px;border:1px solid var(--card-border);border-radius:8px;margin-bottom:16px;}"
1815
+ ".draft{margin-bottom:20px;padding:16px;background:var(--bg);border:1px solid var(--card-border);border-radius:8px;}"
1816
+ ".draft-meta{font-size:13px;color:var(--muted);margin-bottom:8px;}"
1817
+ "@media (max-width: 900px){th{width:180px;}}"
1818
+ )
1819
+
1820
+ return (
1821
+ "<!doctype html>"
1822
+ "<html><head><meta charset='utf-8'>"
1823
+ "<title>FuseSell AI - Sales Process Report</title>"
1824
+ f"<style>{style}</style>"
1825
+ "</head><body>"
1826
+ "<div class='container'>"
1827
+ "<h1>FuseSell AI - Sales Process Report</h1>"
1828
+ "<div class='section'>"
1829
+ "<h2>Process Summary</h2>"
1830
+ "<table>"
1831
+ f"{_row('Execution Id', summary.get('execution_id', ''))}"
1832
+ f"{_row('Status', summary.get('status', ''))}"
1833
+ f"{_row('Started At', summary.get('started_at', ''))}"
1834
+ f"{_row('Duration Seconds', summary.get('duration_seconds', ''))}"
1835
+ f"{_row('Stage Count', summary.get('stage_count', ''))}"
1836
+ f"{_row('Avg. Stage Duration', summary.get('avg_stage_duration', ''))}"
1837
+ f"{_row('Pipeline Overhead (%)', summary.get('pipeline_overhead', ''))}"
1838
+ f"{_row('Slowest Stage', summary.get('slowest_stage', ''))}"
1839
+ f"{_row('Fastest Stage', summary.get('fastest_stage', ''))}"
1840
+ "</table>"
1841
+ "</div>"
1842
+ "<div class='section'>"
1843
+ "<h2>Stage Results</h2>"
1844
+ "<table class='stage-table'>"
1845
+ "<tr><th>Stage</th><th>Duration (s)</th><th>% of Total</th><th>Start Time</th><th>End Time</th></tr>"
1846
+ f"{stage_rows_html or '<tr><td colspan=\"5\">No stage timings available.</td></tr>'}"
1847
+ "</table>"
1848
+ "</div>"
1849
+ "<div class='section'>"
1850
+ "<h2>Customer & Company Info</h2>"
1851
+ "<table>"
1852
+ f"{_row('Company Name', customer.get('company_name', ''))}"
1853
+ f"{_row('Industry', customer.get('industry', ''))}"
1854
+ f"{_row('Website', customer.get('website', ''))}"
1855
+ f"{_row('Contact Name', customer.get('contact_name', ''))}"
1856
+ f"{_row('Contact Email', customer.get('contact_email', ''))}"
1857
+ f"{_row('Funding Sources', customer.get('funding_sources', ''))}"
1858
+ f"{_row('Revenue Last 3 Years', customer.get('revenue_last_three_years', ''))}"
1859
+ f"{_row('Overall Rating', customer.get('overall_rating', ''))}"
1860
+ f"{_row('Recommendations', customer.get('recommendations', ''))}"
1861
+ "</table>"
1862
+ "</div>"
1863
+ "<div class='section'>"
1864
+ "<h2>Pain Points</h2>"
1865
+ "<table>"
1866
+ "<tr><th>Category</th><th>Description</th><th>Impact</th></tr>"
1867
+ f"{pain_rows_html or '<tr><td colspan=\"3\">No pain points captured.</td></tr>'}"
1868
+ "</table>"
1869
+ "</div>"
1870
+ "<div class='section'>"
1871
+ "<h2>Tech Stack & Innovation</h2>"
1872
+ f"<div class='chips'>{tech_chips or '<span class=\"chip\">Not specified</span>'}</div>"
1873
+ "<h4>Innovation Gaps & Opportunities</h4>"
1874
+ f"<ul>{innovation_list or '<li>No innovation insights captured.</li>'}</ul>"
1875
+ "</div>"
1876
+ "<div class='section'>"
1877
+ "<h2>Lead Scoring - Product Fit</h2>"
1878
+ "<table>"
1879
+ f"{_row('Product Name', lead_fit.get('product_name', ''))}"
1880
+ f"{_row('Industry Fit', lead_fit.get('industry_fit', ''))}"
1881
+ f"{_row('Pain Points Addressed', lead_fit.get('pain_points_addressed', ''))}"
1882
+ f"{_row('Geographic Market Fit', lead_fit.get('geographic_market_fit', ''))}"
1883
+ f"{_row('Total Weighted Score', lead_fit.get('total_weighted_score', ''))}"
1884
+ f"{_row('Recommendation', lead_fit.get('recommendation', ''))}"
1885
+ "</table>"
1886
+ "</div>"
1887
+ "<div class='section'>"
1888
+ "<h2>Email Outreach Drafts</h2>"
1889
+ "<details open>"
1890
+ "<summary>Show All Drafts</summary>"
1891
+ f"{''.join(draft_html_parts) if draft_html_parts else '<div>No email drafts generated.</div>'}"
1892
+ "</details>"
1893
+ "</div>"
1894
+ "<div class='section'>"
1895
+ "<h2>Raw JSON</h2>"
1896
+ "<details>"
1897
+ "<summary>View Full Raw JSON</summary>"
1898
+ "<pre>"
1899
+ f"{raw_escaped}"
1900
+ "</pre>"
1901
+ "</details>"
1902
+ "</div>"
1903
+ "</div>"
1904
+ "</body></html>"
1905
+ )
1906
+
1907
+
1908
+ def _render_query_html(payload: Dict[str, Any], raw_json: str) -> str:
1909
+ """Render the query sales process HTML output."""
1910
+ filters = payload.get('filters') if isinstance(payload, dict) else {}
1911
+ results = payload.get('results') if isinstance(payload, dict) else []
1912
+ raw_escaped = html.escape(raw_json)
1913
+ filters_rows = _render_filters(filters)
1914
+ result_cards = _render_results(results if isinstance(results, list) else [])
1915
+
1916
+ style = (
1917
+ CSS_THEME_VARS
1918
+ + "html,body{margin:0;padding:0;}"
1919
+ "body{font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.6;transition:background 0.2s,color 0.2s;}"
1920
+ "*{box-sizing:border-box;}"
1921
+ ".container{max-width:1200px;margin:40px auto;background:var(--card-bg);border-radius:10px;box-shadow:0 4px 16px var(--shadow-dark);padding:40px 36px;}"
1922
+ "h1,h2,h3,h4{color:var(--muted);}"
1923
+ ".section{margin-bottom:24px;}"
1924
+ "table{border-collapse:collapse;width:100%;margin-bottom:12px;table-layout:fixed;}"
1925
+ "th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--card-border);word-break:break-word;}"
1926
+ "th{background:var(--card-border);font-weight:600;width:240px;}"
1927
+ ".badge{display:inline-block;background:var(--accent-soft);color:var(--text);padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px;}"
1928
+ "details{margin-top:16px;}"
1929
+ "summary{cursor:pointer;color:var(--accent);font-weight:600;}"
1930
+ "pre{background:var(--card-border);border-radius:8px;padding:12px;font-size:13px;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;overflow-x:auto;}"
1931
+ "ul,li{margin:0;padding:0;list-style:none;}"
1932
+ ".chips{display:flex;gap:10px;flex-wrap:wrap;margin:4px 0;}"
1933
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 14px;border-radius:999px;font-weight:600;font-size:12px;}"
1934
+ ".iframe-draft{width:100%;min-height:340px;border:1px solid var(--card-border);border-radius:8px;margin-bottom:16px;}"
1935
+ ".kv{display:grid;grid-template-columns:240px minmax(0,1fr);gap:8px 14px;padding:10px 0;border-bottom:1px solid var(--card-border);align-items:flex-start;}"
1936
+ ".kv:last-child{border-bottom:none;} .list-item{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;margin:8px 0;}"
1937
+ ".k{font-weight:600;color:var(--text);background:var(--card-border);border-radius:6px;padding:8px 10px;}"
1938
+ ".v{color:var(--text);min-width:0;}"
1939
+ ".text{background:var(--card-border);border-radius:6px;padding:8px 10px;display:block;width:100%;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;}"
1940
+ ".nested-card{border:1px solid var(--card-border);background:var(--bg);border-radius:8px;padding:10px;}"
1941
+ ".pre-inline{background:var(--card-border);color:var(--text);padding:10px;border-radius:6px;overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;}"
1942
+ ".html-preview{display:flex;flex-direction:column;gap:8px;}"
1943
+ ".html-preview-label{font-weight:600;color:var(--text);font-size:13px;}"
1944
+ ".html-iframe{width:100%;min-height:340px;border:0;display:block;}"
1945
+ ".html-raw summary{cursor:pointer;color:var(--accent);font-weight:600;}"
1946
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;padding:24px;margin-bottom:24px;box-shadow:0 2px 8px var(--shadow);}"
1947
+ ".muted{color:var(--muted);font-style:italic;}"
1948
+ ".draft{margin-bottom:20px;padding:16px;background:var(--bg);border:1px solid var(--card-border);border-radius:8px;}"
1949
+ ".criteria-list{display:flex;flex-direction:column;gap:12px;padding:10px;}"
1950
+ ".criteria-item{background:var(--bg);border:1px solid var(--card-border);border-radius:6px;padding:10px;}"
1951
+ ".criteria-header{display:flex;align-items:center;gap:10px;margin-bottom:8px;}"
1952
+ ".criteria-num{display:inline-flex;align-items:center;justify-content:center;min-width:24px;height:24px;background:var(--accent);color:#fff;border-radius:50%;font-weight:700;font-size:12px;flex-shrink:0;}"
1953
+ ".criteria-label{font-weight:600;color:var(--text);font-size:14px;flex:1;min-width:0;word-break:break-word;}"
1954
+ ".criteria-content{margin-left:34px;color:var(--muted);font-size:13px;line-height:1.6;}"
1955
+ ".criteria-content>div{margin-bottom:4px;}"
1956
+ ".criteria-content>div:last-child{margin-bottom:0;}"
1957
+ "@media (max-width: 900px){th{width:180px;} .kv{grid-template-columns:minmax(140px,1fr) minmax(0,2fr);padding:8px 10px;}}"
1958
+ )
1959
+
1960
+ return (
1961
+ "<!doctype html>"
1962
+ "<html><head><meta charset='utf-8'>"
1963
+ "<title>FuseSell AI - Query Results</title>"
1964
+ f"<style>{style}</style>"
1965
+ "</head><body>"
1966
+ "<div class='container'>"
1967
+ "<h1>FuseSell AI - Query Results</h1>"
1968
+ "<div class='section'>"
1969
+ "<h2>Filters</h2>"
1970
+ "<table>" + filters_rows + "</table>"
1971
+ "</div>"
1972
+ + ("<div class='section'><h2>Results</h2>" + (result_cards or "<div>No results found.</div>") + "</div>")
1973
+ + "<div class='section'>"
1974
+ + "<h2>Raw JSON</h2>"
1975
+ + "<details><summary>View Full Raw JSON</summary><pre>" + raw_escaped + "</pre></details>"
1976
+ + "</div>"
1977
+ + "</div>"
1978
+ + "</body></html>"
1979
+ )
1980
+
1981
+
1982
+ def _render_list_products_html(products: List[Dict[str, Any]], raw_json: str) -> str:
1983
+ """Render list_products_compact output with product cards."""
1984
+ raw_escaped = html.escape(raw_json)
1985
+
1986
+ cards: List[str] = []
1987
+ for idx, product in enumerate(products, start=1):
1988
+ if not isinstance(product, dict):
1989
+ continue
1990
+
1991
+ product_name = html.escape(str(product.get("product_name") or product.get("productName") or f"Product {idx}"))
1992
+ product_id = html.escape(str(product.get("product_id") or ""))
1993
+ status = html.escape(str(product.get("status") or "unknown"))
1994
+
1995
+ # Key product info
1996
+ info_rows = []
1997
+ for key in ("product_id", "product_name", "short_description", "category", "subcategory", "status", "project_code", "created_at", "updated_at"):
1998
+ val = product.get(key)
1999
+ if val not in (None, "", [], {}):
2000
+ info_rows.append(
2001
+ f"<div class='kv'><div class='k'>{html.escape(_friendly_key(str(key)))}</div>"
2002
+ f"<div class='v'>{html.escape(str(val))}</div></div>"
2003
+ )
2004
+
2005
+ # Long description
2006
+ long_desc = product.get("long_description")
2007
+ long_desc_section = ""
2008
+ if long_desc:
2009
+ long_desc_section = (
2010
+ "<div class='section'><h3>Description</h3>"
2011
+ f"<p style='color:var(--text);line-height:1.6;'>{html.escape(str(long_desc))}</p></div>"
2012
+ )
2013
+
2014
+ # Arrays like target_users, key_features, unique_selling_points
2015
+ arrays_section = ""
2016
+ for key in ("target_users", "key_features", "unique_selling_points"):
2017
+ val = product.get(key)
2018
+ if isinstance(val, list) and val:
2019
+ chips = "".join(f"<span class='chip'>{html.escape(str(item))}</span>" for item in val)
2020
+ arrays_section += (
2021
+ f"<div class='section'><h3>{html.escape(_friendly_key(key))}</h3>"
2022
+ f"<div style='display:flex;flex-wrap:wrap;gap:6px;'>{chips}</div></div>"
2023
+ )
2024
+
2025
+ # Pricing info
2026
+ pricing = product.get("pricing")
2027
+ pricing_section = ""
2028
+ if pricing:
2029
+ pricing_section = (
2030
+ f"<div class='section'><h3>Pricing</h3>"
2031
+ f"<div class='kv'><div class='k'>Pricing</div><div class='v'>{html.escape(str(pricing))}</div></div>"
2032
+ "</div>"
2033
+ )
2034
+
2035
+ cards.append(
2036
+ "<div class='card'>"
2037
+ "<div class='card-header'>"
2038
+ f"<span class='result-badge'>#{idx}</span>"
2039
+ f"<h2>{product_name}</h2>"
2040
+ "</div>"
2041
+ f"<div class='section'><h3>Product Information</h3>{''.join(info_rows)}</div>"
2042
+ f"{long_desc_section}"
2043
+ f"{arrays_section}"
2044
+ f"{pricing_section}"
2045
+ "</div>"
2046
+ )
2047
+
2048
+ cards_html = "".join(cards) or "<div class='muted'>No products found.</div>"
2049
+
2050
+ return (
2051
+ "<!doctype html><html><head><meta charset='utf-8'>"
2052
+ "<title>FuseSell AI - Product List</title>"
2053
+ f"<style>{CSS_THEME_VARS}"
2054
+ "body{margin:0;font-family:'Segoe UI','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text);"
2055
+ "line-height:1.6;padding:16px;transition:background 0.2s,color 0.2s;}"
2056
+ ".container{width:100%;max-width:none;margin:0 auto;}"
2057
+ ".section{margin-bottom:24px;}"
2058
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;"
2059
+ "padding:18px 20px;box-shadow:0 2px 8px var(--shadow);margin-bottom:18px;}"
2060
+ ".card-header{display:flex;align-items:center;gap:12px;margin-bottom:12px;}"
2061
+ ".result-badge{display:inline-flex;align-items:center;justify-content:center;"
2062
+ "background:var(--accent-soft);color:var(--text);padding:6px 10px;border-radius:999px;"
2063
+ "font-weight:700;font-size:14px;}"
2064
+ ".kv{display:grid;grid-template-columns:200px minmax(0,1fr);gap:8px 14px;padding:8px 0;"
2065
+ "border-bottom:1px solid var(--card-border);}"
2066
+ ".kv:last-child{border-bottom:none;}"
2067
+ ".k{font-weight:600;color:var(--text);}"
2068
+ ".v{color:var(--text);}"
2069
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 10px;border-radius:999px;"
2070
+ "font-weight:600;font-size:12px;margin-left:8px;}"
2071
+ ".muted{color:var(--muted);font-style:italic;}"
2072
+ ".pre-inline{background:var(--pre-bg);color:var(--pre-text);padding:8px;border-radius:8px;"
2073
+ "overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;"
2074
+ "font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
2075
+ "</style></head><body>"
2076
+ "<div class='container'>"
2077
+ "<h1>FuseSell AI - Product List</h1>"
2078
+ "<div class='section'>"
2079
+ "<h2>Products</h2>"
2080
+ f"{cards_html}"
2081
+ "</div>"
2082
+ "<details><summary>View Raw JSON</summary>"
2083
+ f"<pre class='pre-inline'>{raw_escaped}</pre>"
2084
+ "</details>"
2085
+ "</div>"
2086
+ "<script>"
2087
+ "function applyTheme(theme){"
2088
+ "document.body.classList.remove('light','dark');"
2089
+ "document.body.classList.add(theme);"
2090
+ "}"
2091
+ "window.parent.postMessage({type:'get-theme'},'*');"
2092
+ "window.addEventListener('message',(event)=>{"
2093
+ "if(event.data?.type==='theme-response'||event.data?.type==='theme-change'){"
2094
+ "applyTheme(event.data.theme);"
2095
+ "}"
2096
+ "});"
2097
+ "setTimeout(()=>{"
2098
+ "if(!document.body.classList.contains('light')&&!document.body.classList.contains('dark')){"
2099
+ "applyTheme(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');"
2100
+ "}"
2101
+ "},100);"
2102
+ "const resizeObserver=new ResizeObserver((entries)=>{"
2103
+ "entries.forEach((entry)=>{"
2104
+ "window.parent.postMessage({type:'ui-size-change',payload:{height:entry.contentRect.height}},'*');"
2105
+ "});"
2106
+ "});"
2107
+ "resizeObserver.observe(document.documentElement);"
2108
+ "</script>"
2109
+ "</body></html>"
2110
+ )
2111
+
2112
+
2113
+ def _render_list_drafts_html(payload: Dict[str, Any], raw_json: str) -> str:
2114
+ """Render list_drafts_compact output with rendered HTML email bodies."""
2115
+ items = payload.get("items") if isinstance(payload, dict) else []
2116
+ filters = payload.get("filters") if isinstance(payload, dict) else {}
2117
+ raw_escaped = html.escape(raw_json)
2118
+
2119
+ def _render_filters_table(data: Dict[str, Any]) -> str:
2120
+ if not isinstance(data, dict):
2121
+ return ""
2122
+ rows = []
2123
+ for key, val in data.items():
2124
+ display = _first_non_empty(val)
2125
+ if display is None or display == "":
2126
+ continue
2127
+ rows.append(f"<tr><th>{html.escape(_friendly_key(str(key)))}</th><td>{html.escape(display)}</td></tr>")
2128
+ return "".join(rows)
2129
+
2130
+ def _render_task_card(task: Dict[str, Any]) -> str:
2131
+ title = (
2132
+ task.get("title")
2133
+ or _first_non_empty(task.get("company_name"), task.get("contact_email"), task.get("task_id"))
2134
+ or "Drafts"
2135
+ )
2136
+ chips = []
2137
+ if task.get("company_name"):
2138
+ chips.append(f"<span class='chip'>{html.escape(str(task.get('company_name')))}</span>")
2139
+ if task.get("contact_email"):
2140
+ chips.append(f"<span class='chip muted-chip'>{html.escape(str(task.get('contact_email')))}</span>")
2141
+ if task.get("execution_status"):
2142
+ chips.append(f"<span class='chip'>{html.escape(str(task.get('execution_status')))}</span>")
2143
+ if task.get("task_created_at"):
2144
+ chips.append(f"<span class='chip muted-chip'>{html.escape(_format_timestamp(task.get('task_created_at')))}"
2145
+ "</span>")
2146
+ selected_ids = task.get("selected_draft_ids")
2147
+ if selected_ids:
2148
+ chips.append(f"<span class='chip success'>Selected drafts: {len(selected_ids)}</span>")
2149
+
2150
+ drafts_html = _render_all_email_drafts(task.get("drafts"), show_schedule=True)
2151
+ chips_html = f"<div class='chips'>{''.join(chips)}</div>" if chips else ""
2152
+
2153
+ return (
2154
+ "<div class='card'>"
2155
+ "<div class='card-header'>"
2156
+ f"<h3>{html.escape(str(title))}</h3>"
2157
+ f"{chips_html}"
2158
+ "</div>"
2159
+ f"{drafts_html}"
2160
+ "</div>"
2161
+ )
2162
+
2163
+ cards = ""
2164
+ if isinstance(items, list):
2165
+ cards = "".join(_render_task_card(task) for task in items if isinstance(task, dict))
2166
+ cards = cards or "<div class='muted'>No drafts found.</div>"
2167
+
2168
+ filters_rows = _render_filters_table(filters)
2169
+ summary_rows = []
2170
+ if isinstance(payload, dict):
2171
+ if payload.get("count") is not None:
2172
+ summary_rows.append(_row("Task Count", _first_non_empty(payload.get("count"))))
2173
+ if payload.get("drafts") is not None:
2174
+ summary_rows.append(_row("Drafts", _first_non_empty(payload.get("drafts"))))
2175
+ summary_html = (
2176
+ "<div class='section'><h2>Summary</h2><table>"
2177
+ f"{''.join(summary_rows) or '<tr><td colspan=\"2\">No summary available.</td></tr>'}"
2178
+ "</table></div>"
2179
+ )
2180
+ filters_section = (
2181
+ f"<div class='section'><h2>Filters</h2><table>{filters_rows}</table></div>" if filters_rows else ""
2182
+ )
2183
+
2184
+ style = (
2185
+ CSS_THEME_VARS
2186
+ + "html,body{margin:0;padding:0;}"
2187
+ "body{font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;color:var(--text);background:var(--bg);line-height:1.6;transition:background 0.2s,color 0.2s;}"
2188
+ "*{box-sizing:border-box;}"
2189
+ ".container{max-width:1200px;margin:32px auto;background:var(--card-bg);border-radius:10px;box-shadow:0 4px 16px var(--shadow-dark);padding:32px;}"
2190
+ ".section{margin-bottom:24px;}"
2191
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;padding:18px 20px;box-shadow:0 2px 8px var(--shadow);margin-bottom:16px;}"
2192
+ ".card-header{display:flex;flex-direction:column;gap:8px;margin-bottom:12px;}"
2193
+ "h1,h2,h3,h4{color:var(--text);margin:0;}"
2194
+ "table{border-collapse:collapse;width:100%;margin-bottom:12px;table-layout:fixed;}"
2195
+ "th,td{text-align:left;padding:10px 14px;border-bottom:1px solid var(--card-border);word-break:break-word;}"
2196
+ "th{background:var(--card-border);font-weight:600;width:240px;}"
2197
+ ".chips{display:flex;gap:10px;flex-wrap:wrap;}"
2198
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 14px;border-radius:999px;font-weight:600;font-size:12px;}"
2199
+ ".chip.success{background:#dcfce7;color:#166534;}"
2200
+ ".chip.warn{background:#fef9c3;color:#92400e;}"
2201
+ ".chip.muted-chip{background:var(--card-border);color:var(--text);}"
2202
+ ".chip.strong{background:var(--accent);color:var(--text);font-weight:700;}"
2203
+ ".muted{color:var(--muted);font-style:italic;}"
2204
+ ".draft{margin-bottom:20px;padding:14px;background:var(--bg);border:1px solid var(--card-border);border-radius:8px;}"
2205
+ ".draft-header{display:flex;align-items:center;gap:10px;margin-bottom:8px;}"
2206
+ ".draft-label{background:var(--accent);color:var(--text);font-weight:700;border-radius:8px;padding:6px 10px;display:inline-flex;align-items:center;gap:6px;}"
2207
+ ".iframe-draft{width:100%;min-height:320px;border:1px solid var(--card-border);border-radius:8px;margin-top:10px;}"
2208
+ ".html-preview{display:flex;flex-direction:column;gap:8px;}"
2209
+ ".schedule-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:8px 0;}"
2210
+ ".pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-weight:700;font-size:12px;}"
2211
+ ".pill-warn{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;}"
2212
+ ".pill-muted{background:var(--card-border);color:var(--text);}"
2213
+ ".schedule-time{font-weight:700;color:var(--text);}"
2214
+ "details{margin-top:16px;}"
2215
+ "summary{cursor:pointer;color:var(--accent);font-weight:600;}"
2216
+ ".pre{background:var(--pre-bg);color:var(--pre-text);padding:12px;border-radius:8px;overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
2217
+ "@media (max-width: 900px){th{width:180px;}}"
2218
+ )
2219
+
2220
+ return (
2221
+ "<!doctype html>"
2222
+ "<html><head><meta charset='utf-8'>"
2223
+ "<title>FuseSell AI - Email Drafts</title>"
2224
+ f"<style>{style}</style>"
2225
+ "</head><body>"
2226
+ "<div class='container'>"
2227
+ "<h1>FuseSell AI - Email Drafts</h1>"
2228
+ f"{summary_html}"
2229
+ f"{filters_section}"
2230
+ "<div class='section'>"
2231
+ "<h2>Drafts</h2>"
2232
+ f"{cards}"
2233
+ "</div>"
2234
+ "<div class='section'>"
2235
+ "<h2>Raw JSON</h2>"
2236
+ "<details><summary>View Full Raw JSON</summary>"
2237
+ f"<pre class='pre'>{raw_escaped}</pre>"
2238
+ "</details>"
2239
+ "</div>"
2240
+ "</div>"
2241
+ "</body></html>"
2242
+ )
2243
+
2244
+
2245
+ def write_full_output_html(
2246
+ full_payload: Any,
2247
+ *,
2248
+ flow_name: str,
2249
+ data_dir: Path,
2250
+ hidden_keys: Optional[Set[str]] = None,
2251
+ root_hidden_keys: Optional[Set[str]] = None,
2252
+ html_render_keys: Optional[Set[str]] = None,
2253
+ ) -> Optional[dict]:
2254
+ """
2255
+ Write a friendly HTML view of the payload plus raw JSON for debugging.
2256
+ """
2257
+ try:
2258
+ html_dir = Path(data_dir) / "full_outputs"
2259
+ html_dir.mkdir(parents=True, exist_ok=True)
2260
+
2261
+ hidden = hidden_keys or DEFAULT_HIDDEN_KEYS
2262
+ root_hidden = root_hidden_keys or DEFAULT_ROOT_HIDDEN_KEYS
2263
+ render_keys = html_render_keys or DEFAULT_HTML_RENDER_KEYS
2264
+
2265
+ sanitized = _sanitize_for_json(full_payload)
2266
+ raw_serialized = json.dumps(sanitized, indent=2, ensure_ascii=False)
2267
+
2268
+ # Specialized renderer for start_sales_process_compact (match flow fallback)
2269
+ if flow_name == "start_sales_process_compact":
2270
+ content = _render_start_sales_process_html(sanitized, raw_serialized)
2271
+ filename = f"{flow_name}_{uuid4().hex}.html"
2272
+ path = html_dir / filename
2273
+ path.write_text(content, encoding="utf-8")
2274
+ stat_result = path.stat()
2275
+ timestamp = f"{datetime.utcnow().isoformat()}Z"
2276
+ return {
2277
+ "path": str(path),
2278
+ "metadata": {
2279
+ "mime": "text/html",
2280
+ "size": stat_result.st_size,
2281
+ "created": timestamp,
2282
+ "filename": filename,
2283
+ "originalFilename": filename,
2284
+ },
2285
+ }
2286
+
2287
+ if flow_name == "list_products_compact":
2288
+ products_list = sanitized if isinstance(sanitized, list) else []
2289
+ content = _render_list_products_html(products_list, raw_serialized)
2290
+ filename = f"{flow_name}_{uuid4().hex}.html"
2291
+ path = html_dir / filename
2292
+ path.write_text(content, encoding="utf-8")
2293
+ stat_result = path.stat()
2294
+ return {
2295
+ "path": str(path),
2296
+ "metadata": {
2297
+ "mime": "text/html",
2298
+ "size": stat_result.st_size,
2299
+ "created": f"{datetime.utcnow().isoformat()}Z",
2300
+ "filename": filename,
2301
+ "originalFilename": filename,
2302
+ },
2303
+ }
2304
+
2305
+ if flow_name == "list_drafts_compact":
2306
+ payload_for_drafts = sanitized if isinstance(sanitized, dict) else {"items": sanitized}
2307
+ content = _render_list_drafts_html(payload_for_drafts, raw_serialized)
2308
+ filename = f"{flow_name}_{uuid4().hex}.html"
2309
+ path = html_dir / filename
2310
+ path.write_text(content, encoding="utf-8")
2311
+ stat_result = path.stat()
2312
+ return {
2313
+ "path": str(path),
2314
+ "metadata": {
2315
+ "mime": "text/html",
2316
+ "size": stat_result.st_size,
2317
+ "created": f"{datetime.utcnow().isoformat()}Z",
2318
+ "filename": filename,
2319
+ "originalFilename": filename,
2320
+ },
2321
+ }
2322
+
2323
+ # Specialized renderer for query sales processes (by flow name or results array)
2324
+ if flow_name == "query_sales_processes_compact" or (
2325
+ isinstance(sanitized, dict) and isinstance(sanitized.get("results"), list)
2326
+ ) or isinstance(sanitized, list):
2327
+ payload_for_query = sanitized if isinstance(sanitized, dict) else {"results": sanitized}
2328
+ content = _render_query_results(payload_for_query, raw_serialized)
2329
+ filename = f"{flow_name}_{uuid4().hex}.html"
2330
+ path = html_dir / filename
2331
+ path.write_text(content, encoding="utf-8")
2332
+ stat_result = path.stat()
2333
+ return {
2334
+ "path": str(path),
2335
+ "metadata": {
2336
+ "mime": "text/html",
2337
+ "size": stat_result.st_size,
2338
+ "created": f"{datetime.utcnow().isoformat()}Z",
2339
+ "filename": filename,
2340
+ "originalFilename": filename,
2341
+ },
2342
+ }
2343
+
2344
+ filename = f"{flow_name}_{uuid4().hex}.html"
2345
+ path = html_dir / filename
2346
+
2347
+ timestamp = f"{datetime.utcnow().isoformat()}Z"
2348
+
2349
+ cleaned = _prune_empty(sanitized, hidden_keys=hidden, root_hidden_keys=root_hidden)
2350
+ display_payload = _humanize_keys(cleaned if cleaned is not None else {"Info": "No non-empty fields"})
2351
+ escaped_raw = html.escape(raw_serialized)
2352
+ friendly_view = _render_value(display_payload, html_render_keys=render_keys)
2353
+
2354
+ content = (
2355
+ "<!doctype html>"
2356
+ "<html><head><meta charset='utf-8'>"
2357
+ f"<title>{html.escape(flow_name)} full output</title>"
2358
+ "<style>"
2359
+ f"{CSS_THEME_VARS}"
2360
+ "body{margin:0;font-family:'Segoe UI','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text);"
2361
+ "line-height:1.6;padding:16px;transition:background 0.2s,color 0.2s;}"
2362
+ ".meta{color:var(--muted);margin-bottom:16px;}"
2363
+ ".card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:10px;padding:14px 16px;"
2364
+ "box-shadow:0 4px 14px var(--shadow);}"
2365
+ ".card + .card{margin-top:12px;}"
2366
+ ".kv{display:grid;grid-template-columns:minmax(120px,max-content) 1fr;gap:6px 10px;padding:6px 0;"
2367
+ "border-bottom:1px solid var(--card-border);}"
2368
+ ".kv:last-child{border-bottom:none;}"
2369
+ ".section-header{font-weight:700;color:var(--text);background:var(--accent-soft);padding:8px 12px;"
2370
+ "border-radius:6px;margin:12px 0 6px 0;font-size:13px;}"
2371
+ ".k{font-weight:600;color:var(--text);}"
2372
+ ".v{color:var(--text);}"
2373
+ ".text{background:var(--card-border);border-radius:6px;padding:4px 8px;display:inline-block;}"
2374
+ ".chips{display:flex;flex-wrap:wrap;gap:6px;}"
2375
+ ".chip{background:var(--accent-soft);color:var(--text);padding:4px 10px;border-radius:999px;font-weight:600;font-size:12px;}"
2376
+ ".badge{display:inline-block;background:var(--card-border);color:var(--text);padding:2px 8px;border-radius:999px;font-weight:700;font-size:11px;}"
2377
+ ".pre{background:var(--pre-bg);color:var(--pre-text);padding:12px;border-radius:8px;"
2378
+ "overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;"
2379
+ "font-size:13px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
2380
+ ".html-preview{margin-top:6px;border:1px solid var(--card-border);border-radius:8px;overflow:hidden;}"
2381
+ ".html-preview-label{background:var(--pre-bg);color:var(--pre-text);padding:6px 10px;font-weight:700;font-size:12px;}"
2382
+ ".html-iframe{width:100%;min-height:240px;border:0;display:block;}"
2383
+ ".html-raw{margin:0;padding:10px;}"
2384
+ ".pre-inline{background:var(--pre-bg);color:var(--pre-text);padding:8px;border-radius:8px;"
2385
+ "overflow:auto;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;"
2386
+ "font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-word;}"
2387
+ "details{margin-top:10px;}"
2388
+ "details summary{cursor:pointer;color:var(--accent);font-weight:600;outline:none;}"
2389
+ "</style>"
2390
+ "</head><body>"
2391
+ f"<div class='meta'><strong>generated_at</strong>: {timestamp}</div>"
2392
+ "<div class='card'>"
2393
+ "<div>"
2394
+ f"{friendly_view}"
2395
+ "</div>"
2396
+ "<details>"
2397
+ "<summary>View Raw JSON</summary>"
2398
+ "<div class='pre' style='margin-top:8px;'>"
2399
+ f"{escaped_raw}"
2400
+ "</div>"
2401
+ "</details>"
2402
+ "</div>"
2403
+ "<script>"
2404
+ "function applyTheme(theme){"
2405
+ "document.body.classList.remove('light','dark');"
2406
+ "document.body.classList.add(theme);"
2407
+ "}"
2408
+ "window.parent.postMessage({type:'get-theme'},'*');"
2409
+ "window.addEventListener('message',(event)=>{"
2410
+ "if(event.data?.type==='theme-response'||event.data?.type==='theme-change'){"
2411
+ "applyTheme(event.data.theme);"
2412
+ "}"
2413
+ "});"
2414
+ "setTimeout(()=>{"
2415
+ "if(!document.body.classList.contains('light')&&!document.body.classList.contains('dark')){"
2416
+ "applyTheme(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');"
2417
+ "}"
2418
+ "},100);"
2419
+ "const resizeObserver=new ResizeObserver((entries)=>{"
2420
+ "entries.forEach((entry)=>{"
2421
+ "window.parent.postMessage({type:'ui-size-change',payload:{height:entry.contentRect.height}},'*');"
2422
+ "});"
2423
+ "});"
2424
+ "resizeObserver.observe(document.documentElement);"
2425
+ "</script>"
2426
+ "</body></html>"
2427
+ )
2428
+
2429
+ path.write_text(content, encoding="utf-8")
2430
+ stat_result = path.stat()
2431
+ return {
2432
+ "path": str(path),
2433
+ "metadata": {
2434
+ "mime": "text/html",
2435
+ "size": stat_result.st_size,
2436
+ "created": timestamp,
2437
+ "filename": filename,
2438
+ "originalFilename": filename,
2439
+ },
2440
+ }
2441
+ except Exception as exc: # noqa: BLE001
2442
+ print(f"Warning: failed to write full output HTML for {flow_name}: {exc}", file=sys.stderr)
2443
+ return None