@@ -112,20 +112,14 @@ def find_process_result(step_name: str):
112112 )
113113 )
114114
115- total_evaluated_fields_count = evaluated_result .confidence .get (
116- "total_evaluated_fields_count" , 0
117- )
118- schema_score = (
119- 0
120- if total_evaluated_fields_count == 0
121- else round (
122- (
123- len (evaluated_result .comparison_result .items )
124- - evaluated_result .confidence ["zero_confidence_fields_count" ]
125- )
126- / len (evaluated_result .comparison_result .items ),
127- 3 ,
128- )
115+ # Compute the aggregate scores. Successful (Completed) processing
116+ # always yields numeric scores: when probabilistic confidence is
117+ # available (logprobs from non-reasoning models / Content Understanding
118+ # signal) we use it; otherwise we fall back to a structural
119+ # completeness score (fraction of expected fields actually filled).
120+ # Failed runs and genuinely empty extractions remain at ``0.0``.
121+ entity_score , schema_score , min_extracted_entity_score = (
122+ self ._derive_aggregate_scores (evaluated_result )
129123 )
130124
131125 processed_result = ContentProcess (
@@ -143,11 +137,9 @@ def find_process_result(step_name: str):
143137 self ._current_message_context .data_pipeline .pipeline_status .creation_time ,
144138 "%Y-%m-%dT%H:%M:%S.%fZ" ,
145139 ),
146- entity_score = evaluated_result . confidence [ "overall_confidence" ] ,
140+ entity_score = entity_score ,
147141 schema_score = schema_score ,
148- min_extracted_entity_score = evaluated_result .confidence [
149- "min_extracted_field_confidence"
150- ],
142+ min_extracted_entity_score = min_extracted_entity_score ,
151143 prompt_tokens = evaluated_result .prompt_tokens ,
152144 completion_tokens = evaluated_result .completion_tokens ,
153145 target_schema = Schema .get_schema (
@@ -241,3 +233,85 @@ def _summarize_processed_time(self, step_results: list[StepResult]) -> str:
241233 # Format the total elapsed time as a string
242234 formatted_elapsed_time = f"{ total_hours :02} :{ total_minutes :02} :{ total_seconds :02} .{ total_milliseconds :03} "
243235 return formatted_elapsed_time
236+
237+ @staticmethod
238+ def _is_filled_value (value : object ) -> bool :
239+ """Heuristic: does an extracted value count as "actually filled"?
240+
241+ Treats ``None``, empty strings, whitespace-only strings, and empty
242+ containers as *not* filled. Recursively descends into dicts/lists so a
243+ nested object that contains only nulls is still counted as empty.
244+ """
245+ if value is None :
246+ return False
247+ if isinstance (value , bool ):
248+ return True
249+ if isinstance (value , str ):
250+ return value .strip () != ""
251+ if isinstance (value , dict ):
252+ return any (SaveHandler ._is_filled_value (v ) for v in value .values ())
253+ if isinstance (value , (list , tuple , set )):
254+ return any (SaveHandler ._is_filled_value (v ) for v in value )
255+ return True
256+
257+ @staticmethod
258+ def _derive_aggregate_scores (
259+ evaluated_result : DataExtractionResult ,
260+ ) -> tuple [float , float , float ]:
261+ """Compute ``(entity_score, schema_score, min_extracted_entity_score)``.
262+
263+ Score selection order:
264+
265+ 1. **Probabilistic confidence** — when the evaluate step produced
266+ per-field confidence (``total_evaluated_fields_count > 0``), use the
267+ probabilistic ``overall_confidence`` plus the ratio of
268+ above-threshold fields. This is the highest-fidelity signal.
269+
270+ 2. **Structural completeness fallback** — when no probabilistic
271+ signal was produced (e.g. reasoning models like ``gpt-5``/``o1``/``o3``
272+ don't return logprobs, and image-only flow has no Content
273+ Understanding signal), but extraction still produced a comparison
274+ table, score by *how much of the schema was actually filled*. This
275+ replaces the old behaviour of falsely emitting ``0%`` for completed
276+ runs that simply lacked logprobs.
277+
278+ 3. **Zero** — only when there is literally no extraction data
279+ (failed pipeline / genuinely empty result). Failed processing
280+ continues to surface as ``0`` so the UI consistently renders
281+ ``0%`` for failures and genuine zeros.
282+ """
283+ confidence = evaluated_result .confidence or {}
284+ total_evaluated_fields_count = confidence .get (
285+ "total_evaluated_fields_count" , 0
286+ )
287+ comparison_items = (
288+ evaluated_result .comparison_result .items
289+ if evaluated_result .comparison_result is not None
290+ else []
291+ )
292+
293+ # Path 1: probabilistic confidence
294+ if total_evaluated_fields_count > 0 and comparison_items :
295+ zero_count = confidence .get ("zero_confidence_fields_count" , 0 )
296+ schema_score = round (
297+ (len (comparison_items ) - zero_count ) / len (comparison_items ),
298+ 3 ,
299+ )
300+ entity_score = float (confidence .get ("overall_confidence" ) or 0.0 )
301+ min_extracted_entity_score = float (
302+ confidence .get ("min_extracted_field_confidence" ) or 0.0
303+ )
304+ return (entity_score , schema_score , min_extracted_entity_score )
305+
306+ # Path 2: structural completeness fallback
307+ if comparison_items :
308+ filled = sum (
309+ 1
310+ for item in comparison_items
311+ if SaveHandler ._is_filled_value (item .Extracted )
312+ )
313+ ratio = round (filled / len (comparison_items ), 3 )
314+ return (ratio , ratio , ratio )
315+
316+ # Path 3: nothing to score on
317+ return (0.0 , 0.0 , 0.0 )
0 commit comments