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.
- fusesell-1.3.42.dist-info/METADATA +873 -0
- fusesell-1.3.42.dist-info/RECORD +35 -0
- fusesell-1.3.42.dist-info/WHEEL +5 -0
- fusesell-1.3.42.dist-info/entry_points.txt +2 -0
- fusesell-1.3.42.dist-info/licenses/LICENSE +21 -0
- fusesell-1.3.42.dist-info/top_level.txt +2 -0
- fusesell.py +20 -0
- fusesell_local/__init__.py +37 -0
- fusesell_local/api.py +343 -0
- fusesell_local/cli.py +1480 -0
- fusesell_local/config/__init__.py +11 -0
- fusesell_local/config/default_email_templates.json +34 -0
- fusesell_local/config/default_prompts.json +19 -0
- fusesell_local/config/default_scoring_criteria.json +154 -0
- fusesell_local/config/prompts.py +245 -0
- fusesell_local/config/settings.py +277 -0
- fusesell_local/pipeline.py +978 -0
- fusesell_local/stages/__init__.py +19 -0
- fusesell_local/stages/base_stage.py +603 -0
- fusesell_local/stages/data_acquisition.py +1820 -0
- fusesell_local/stages/data_preparation.py +1238 -0
- fusesell_local/stages/follow_up.py +1728 -0
- fusesell_local/stages/initial_outreach.py +2972 -0
- fusesell_local/stages/lead_scoring.py +1452 -0
- fusesell_local/utils/__init__.py +36 -0
- fusesell_local/utils/agent_context.py +552 -0
- fusesell_local/utils/auto_setup.py +361 -0
- fusesell_local/utils/birthday_email_manager.py +467 -0
- fusesell_local/utils/data_manager.py +4857 -0
- fusesell_local/utils/event_scheduler.py +959 -0
- fusesell_local/utils/llm_client.py +342 -0
- fusesell_local/utils/logger.py +203 -0
- fusesell_local/utils/output_helpers.py +2443 -0
- fusesell_local/utils/timezone_detector.py +914 -0
- 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
|