2121if TYPE_CHECKING :
2222 from PIL import Image
2323
24- from askui .callbacks .usage_tracking_callback import UsageSummary
24+ from askui .callbacks .conversation_statistics_callback import (
25+ ConversationUsageSummary ,
26+ UsageSummary ,
27+ )
2528
2629
2730def normalize_to_pil_images (
@@ -37,6 +40,27 @@ def normalize_to_pil_images(
3740 return [image ]
3841
3942
43+ def _format_duration (seconds : float ) -> str :
44+ """Format a duration given in seconds as ``HH:MM:SS`` or
45+ ``HH:MM:SS.mmm`` for sub-second precision.
46+
47+ Used by `SimpleHtmlReporter` to render both the overall execution time and
48+ per-conversation durations consistently.
49+ """
50+ total_seconds = max (float (seconds ), 0.0 )
51+ whole_seconds = int (total_seconds )
52+ millis = int (round ((total_seconds - whole_seconds ) * 1000 ))
53+ if millis == 1000 :
54+ whole_seconds += 1
55+ millis = 0
56+ hours , remainder = divmod (whole_seconds , 3600 )
57+ minutes , secs = divmod (remainder , 60 )
58+ base = f"{ hours :02d} :{ minutes :02d} :{ secs :02d} "
59+ if whole_seconds == 0 and millis > 0 :
60+ return f"{ base } .{ millis :03d} "
61+ return base
62+
63+
4064def truncate_base64_images (content : Any ) -> Any :
4165 """Replace base64 image data with a placeholder to keep reports readable.
4266
@@ -1003,13 +1027,17 @@ def generate(self) -> None:
10031027 </p>
10041028 <div class="usage-breakdown-list">
10051029 {% for conversation_usage in usage_summary.per_conversation_summaries %}
1030+ {% set conversation_duration = format_conversation_duration(conversation_usage) %}
10061031 <details class="usage-breakdown-item">
10071032 <summary>
10081033 <span class="usage-breakdown-title">
10091034 Conversation #{{ conversation_usage.conversation_index }}
10101035 </span>
10111036 <span class="usage-breakdown-meta">
10121037 {{ conversation_usage.step_summaries | length }} step(s),
1038+ {% if conversation_duration is not none %}
1039+ Duration: {{ conversation_duration }},
1040+ {% endif %}
10131041 Input {{ "{:,}".format(conversation_usage.input_tokens or 0) }},
10141042 Output {{ "{:,}".format(conversation_usage.output_tokens or 0) }},
10151043 Cache Create {{ "{:,}".format(conversation_usage.cache_creation_input_tokens or 0) }},
@@ -1026,6 +1054,9 @@ def generate(self) -> None:
10261054 <table class="nested-table">
10271055 <tr>
10281056 <th>Conversation ID</th>
1057+ {% if conversation_duration is not none %}
1058+ <th>Duration</th>
1059+ {% endif %}
10291060 <th>Input Tokens</th>
10301061 <th>Output Tokens</th>
10311062 <th>Cache Create</th>
@@ -1036,6 +1067,9 @@ def generate(self) -> None:
10361067 </tr>
10371068 <tr class="system">
10381069 <td class="mono">{{ conversation_usage.conversation_id }}</td>
1070+ {% if conversation_duration is not none %}
1071+ <td>{{ conversation_duration }}</td>
1072+ {% endif %}
10391073 <td>{{ "{:,}".format(conversation_usage.input_tokens or 0) }}</td>
10401074 <td>{{ "{:,}".format(conversation_usage.output_tokens or 0) }}</td>
10411075 <td>{{ "{:,}".format(conversation_usage.cache_creation_input_tokens or 0) }}</td>
@@ -1141,10 +1175,28 @@ def generate(self) -> None:
11411175 end_time = datetime .now (tz = timezone .utc )
11421176 execution_time_formatted : str | None = None
11431177 if self ._start_time is not None :
1144- total_secs = int ((end_time - self ._start_time ).total_seconds ())
1145- hours , remainder = divmod (total_secs , 3600 )
1146- minutes , secs = divmod (remainder , 60 )
1147- execution_time_formatted = f"{ hours :02d} :{ minutes :02d} :{ secs :02d} "
1178+ execution_time_formatted = _format_duration (
1179+ (end_time - self ._start_time ).total_seconds ()
1180+ )
1181+
1182+ def _format_conversation_duration (
1183+ conversation_usage : "ConversationUsageSummary" ,
1184+ ) -> str | None :
1185+ """Derive the formatted conversation duration from stored timestamps.
1186+
1187+ Returns ``None`` if either ``started_at`` or ``ended_at`` is missing
1188+ so the template can skip rendering.
1189+ """
1190+ if (
1191+ conversation_usage .started_at is None
1192+ or conversation_usage .ended_at is None
1193+ ):
1194+ return None
1195+ return _format_duration (
1196+ (
1197+ conversation_usage .ended_at - conversation_usage .started_at
1198+ ).total_seconds ()
1199+ )
11481200
11491201 html = template .render (
11501202 timestamp = end_time ,
@@ -1153,6 +1205,7 @@ def generate(self) -> None:
11531205 usage_summary = self .usage_summary ,
11541206 cache_original_usage = self .cache_original_usage ,
11551207 execution_time_formatted = execution_time_formatted ,
1208+ format_conversation_duration = _format_conversation_duration ,
11561209 )
11571210
11581211 report_path = (
0 commit comments