Skip to content

Commit 56c724f

Browse files
refactor(scoring): structural completeness fallback instead of N/A
Per feedback: Completed runs must always show a meaningful number; Failed runs and genuine zeros stay at 0%. - save_handler._derive_aggregate_scores picks the best available signal: (1) probabilistic confidence when logprobs available; (2) structural completeness (filled fields / total) when no logprobs (reasoning models, image-only flow); (3) 0.0 when no extraction data at all. - _is_filled_value heuristic: None/empty/whitespace count as not filled; descends into nested dicts/lists. - Reverted models from Optional[float]=None back to default 0.0. - Reverted frontend: no N/A path; renders 0% for null/missing scores. - 15 new tests covering all 3 paths + _is_filled_value heuristic.
1 parent dd66aa7 commit 56c724f

15 files changed

Lines changed: 274 additions & 176 deletions

File tree

src/ContentProcessor/src/libs/models/content_process.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ class ContentProcess(BaseModel):
6767
last_modified_time: datetime.datetime = datetime.datetime.now(datetime.UTC)
6868
last_modified_by: Optional[str] = None
6969
status: str
70-
entity_score: Optional[float] = None
71-
min_extracted_entity_score: Optional[float] = None
72-
schema_score: Optional[float] = None
70+
entity_score: Optional[float] = 0.0
71+
min_extracted_entity_score: Optional[float] = 0.0
72+
schema_score: Optional[float] = 0.0
7373
result: Optional[dict] = None
7474
confidence: Optional[dict] = None
7575
target_schema: Optional[Schema] = None

src/ContentProcessor/src/libs/pipeline/handlers/save_handler.py

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,12 @@ def find_process_result(step_name: str):
112112
)
113113
)
114114

115-
# Determine whether per-field confidence could actually be computed.
116-
# When `total_evaluated_fields_count == 0`, no field-level confidence
117-
# signal was produced (e.g. logprobs unavailable on reasoning models, or
118-
# an image flow with no Content Understanding signal). In that case the
119-
# entity/schema scores are *unavailable* rather than genuinely zero, and
120-
# we propagate ``None`` so downstream consumers (API + UI) can render
121-
# an explicit "N/A" instead of a misleading "0%".
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``.
122121
entity_score, schema_score, min_extracted_entity_score = (
123122
self._derive_aggregate_scores(evaluated_result)
124123
)
@@ -235,22 +234,51 @@ def _summarize_processed_time(self, step_results: list[StepResult]) -> str:
235234
formatted_elapsed_time = f"{total_hours:02}:{total_minutes:02}:{total_seconds:02}.{total_milliseconds:03}"
236235
return formatted_elapsed_time
237236

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+
238257
@staticmethod
239258
def _derive_aggregate_scores(
240259
evaluated_result: DataExtractionResult,
241-
) -> tuple[float | None, float | None, float | None]:
260+
) -> tuple[float, float, float]:
242261
"""Compute ``(entity_score, schema_score, min_extracted_entity_score)``.
243262
244-
Returns ``(None, None, None)`` when no per-field confidence signal was
245-
produced (i.e. ``total_evaluated_fields_count == 0`` or there are no
246-
comparison items). This happens, for example, when the LLM call could
247-
not return logprobs (reasoning models) and there is no Content
248-
Understanding signal to fall back on. Treating that case as "unknown"
249-
rather than ``0.0`` lets the API and UI render "N/A" instead of a
250-
misleading "0%".
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.
251277
252-
A genuine zero confidence (e.g. a model that emitted fields but
253-
every token had ``logprob == -inf``) is preserved verbatim.
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.
254282
"""
255283
confidence = evaluated_result.confidence or {}
256284
total_evaluated_fields_count = confidence.get(
@@ -261,14 +289,29 @@ def _derive_aggregate_scores(
261289
if evaluated_result.comparison_result is not None
262290
else []
263291
)
264-
if total_evaluated_fields_count == 0 or not comparison_items:
265-
return (None, None, None)
266292

267-
zero_count = confidence.get("zero_confidence_fields_count", 0)
268-
schema_score = round(
269-
(len(comparison_items) - zero_count) / len(comparison_items),
270-
3,
271-
)
272-
entity_score = confidence.get("overall_confidence")
273-
min_extracted_entity_score = confidence.get("min_extracted_field_confidence")
274-
return (entity_score, schema_score, min_extracted_entity_score)
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)

src/ContentProcessor/tests/unit/pipeline/test_save_handler_scores.py

Lines changed: 141 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33

44
"""Tests for ``SaveHandler._derive_aggregate_scores``.
55
6-
Covers the score-availability semantics:
7-
- valid scores flow through verbatim
8-
- missing per-field signal yields ``None`` (rendered as "N/A" in the UI)
9-
- a genuine zero is preserved as ``0`` (rendered as "0%")
10-
- failed processing (no comparison items) yields ``None``
6+
Covers the score-derivation contract:
7+
- probabilistic confidence flows through verbatim when available
8+
- structural completeness fallback fires for Completed runs without logprobs
9+
(e.g. reasoning models / image-only flow) instead of emitting a misleading 0%
10+
- a genuine zero is preserved as ``0.0``
11+
- failed/empty runs return ``0.0``
1112
"""
1213

1314
from __future__ import annotations
@@ -35,7 +36,7 @@ def _make_result(
3536
)
3637

3738

38-
class TestDeriveAggregateScores:
39+
class TestProbabilisticPath:
3940
def test_valid_scores_flow_through(self):
4041
"""A normal evaluate-step result must produce numeric scores."""
4142
items = [
@@ -63,75 +64,174 @@ def test_valid_scores_flow_through(self):
6364
assert schema == round(2 / 3, 3)
6465
assert min_score == 0.0
6566

66-
def test_missing_per_field_signal_returns_none(self):
67-
"""Reasoning-model / image-only flow: no signal → ``None`` everywhere."""
68-
items: list[ExtractionComparisonItem] = []
67+
def test_all_fields_above_threshold(self):
68+
items = [
69+
ExtractionComparisonItem(
70+
Field="a", Extracted="x", Confidence="95.00%", IsAboveThreshold="True"
71+
),
72+
ExtractionComparisonItem(
73+
Field="b", Extracted="y", Confidence="90.00%", IsAboveThreshold="True"
74+
),
75+
]
6976
confidence = {
70-
"total_evaluated_fields_count": 0,
71-
"overall_confidence": 0.0,
72-
"min_extracted_field_confidence": 0.0,
77+
"total_evaluated_fields_count": 2,
78+
"overall_confidence": 0.925,
79+
"min_extracted_field_confidence": 0.9,
7380
"zero_confidence_fields_count": 0,
7481
}
7582
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
7683
_make_result(items=items, confidence=confidence)
7784
)
78-
assert entity is None
79-
assert schema is None
80-
assert min_score is None
85+
assert entity == 0.925
86+
assert schema == 1.0
87+
assert min_score == 0.9
88+
8189

82-
def test_no_comparison_items_returns_none(self):
83-
"""Even if confidence claims fields exist, an empty comparison list is unknown."""
90+
class TestStructuralFallback:
91+
"""When logprobs are unavailable (reasoning model / image-only) but
92+
extraction succeeded, the Completed file must still get a meaningful
93+
numeric score based on schema completeness."""
94+
95+
def test_all_fields_filled_yields_one(self):
96+
items = [
97+
ExtractionComparisonItem(
98+
Field="a", Extracted="x", Confidence="0.00%", IsAboveThreshold="False"
99+
),
100+
ExtractionComparisonItem(
101+
Field="b", Extracted="y", Confidence="0.00%", IsAboveThreshold="False"
102+
),
103+
ExtractionComparisonItem(
104+
Field="c", Extracted=42, Confidence="0.00%", IsAboveThreshold="False"
105+
),
106+
]
107+
# No probabilistic signal: total_evaluated_fields_count == 0
84108
confidence = {
85-
"total_evaluated_fields_count": 5,
86-
"overall_confidence": 0.9,
87-
"min_extracted_field_confidence": 0.5,
109+
"total_evaluated_fields_count": 0,
110+
"overall_confidence": 0.0,
111+
"min_extracted_field_confidence": 0.0,
88112
"zero_confidence_fields_count": 0,
89113
}
90114
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
91-
_make_result(items=[], confidence=confidence)
115+
_make_result(items=items, confidence=confidence)
92116
)
93-
assert entity is None
94-
assert schema is None
95-
assert min_score is None
117+
assert entity == 1.0
118+
assert schema == 1.0
119+
assert min_score == 1.0
96120

97-
def test_genuine_zero_score_preserved(self):
98-
"""A real ``0`` confidence (e.g. all fields below threshold) must NOT become ``None``."""
121+
def test_partial_fill_yields_ratio(self):
99122
items = [
100123
ExtractionComparisonItem(
101124
Field="a", Extracted="x", Confidence="0.00%", IsAboveThreshold="False"
102125
),
126+
ExtractionComparisonItem(
127+
Field="b", Extracted=None, Confidence="0.00%", IsAboveThreshold="False"
128+
),
129+
ExtractionComparisonItem(
130+
Field="c", Extracted="", Confidence="0.00%", IsAboveThreshold="False"
131+
),
132+
ExtractionComparisonItem(
133+
Field="d", Extracted="z", Confidence="0.00%", IsAboveThreshold="False"
134+
),
135+
]
136+
confidence = {"total_evaluated_fields_count": 0}
137+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
138+
_make_result(items=items, confidence=confidence)
139+
)
140+
# 2 of 4 fields actually filled → 0.5
141+
assert entity == 0.5
142+
assert schema == 0.5
143+
assert min_score == 0.5
144+
145+
def test_all_fields_empty_yields_zero(self):
146+
"""Genuine-empty extraction: structural fallback collapses to ``0.0``."""
147+
items = [
148+
ExtractionComparisonItem(
149+
Field="a", Extracted=None, Confidence="0.00%", IsAboveThreshold="False"
150+
),
151+
ExtractionComparisonItem(
152+
Field="b", Extracted="", Confidence="0.00%", IsAboveThreshold="False"
153+
),
154+
ExtractionComparisonItem(
155+
Field="c", Extracted=" ", Confidence="0.00%", IsAboveThreshold="False"
156+
),
103157
]
158+
confidence = {"total_evaluated_fields_count": 0}
159+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
160+
_make_result(items=items, confidence=confidence)
161+
)
162+
assert entity == 0.0
163+
assert schema == 0.0
164+
assert min_score == 0.0
165+
166+
167+
class TestZeroPath:
168+
def test_no_comparison_items_returns_zero(self):
169+
"""No extraction data at all (failed pipeline) → ``0.0``."""
104170
confidence = {
105-
"total_evaluated_fields_count": 1,
171+
"total_evaluated_fields_count": 0,
106172
"overall_confidence": 0.0,
107173
"min_extracted_field_confidence": 0.0,
108-
"zero_confidence_fields_count": 1,
174+
"zero_confidence_fields_count": 0,
109175
}
110176
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
111-
_make_result(items=items, confidence=confidence)
177+
_make_result(items=[], confidence=confidence)
112178
)
113179
assert entity == 0.0
114180
assert schema == 0.0
115181
assert min_score == 0.0
116182

117-
def test_all_fields_above_threshold(self):
183+
def test_genuine_zero_probabilistic_score_preserved(self):
184+
"""A real ``0`` confidence (every field below threshold) must NOT be
185+
replaced by the structural fallback — it's genuinely 0%."""
118186
items = [
119187
ExtractionComparisonItem(
120-
Field="a", Extracted="x", Confidence="95.00%", IsAboveThreshold="True"
121-
),
122-
ExtractionComparisonItem(
123-
Field="b", Extracted="y", Confidence="90.00%", IsAboveThreshold="True"
188+
Field="a", Extracted="x", Confidence="0.00%", IsAboveThreshold="False"
124189
),
125190
]
126191
confidence = {
127-
"total_evaluated_fields_count": 2,
128-
"overall_confidence": 0.925,
129-
"min_extracted_field_confidence": 0.9,
130-
"zero_confidence_fields_count": 0,
192+
"total_evaluated_fields_count": 1,
193+
"overall_confidence": 0.0,
194+
"min_extracted_field_confidence": 0.0,
195+
"zero_confidence_fields_count": 1,
131196
}
132197
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
133198
_make_result(items=items, confidence=confidence)
134199
)
135-
assert entity == 0.925
136-
assert schema == 1.0
137-
assert min_score == 0.9
200+
assert entity == 0.0
201+
assert schema == 0.0
202+
assert min_score == 0.0
203+
204+
205+
class TestIsFilledValue:
206+
"""Coverage for the ``_is_filled_value`` helper used by the structural fallback."""
207+
208+
def test_none_is_empty(self):
209+
assert SaveHandler._is_filled_value(None) is False
210+
211+
def test_empty_string_is_empty(self):
212+
assert SaveHandler._is_filled_value("") is False
213+
assert SaveHandler._is_filled_value(" ") is False
214+
215+
def test_non_empty_string_is_filled(self):
216+
assert SaveHandler._is_filled_value("x") is True
217+
218+
def test_zero_int_is_filled(self):
219+
# A literal ``0`` is a valid extracted value (e.g. count fields).
220+
assert SaveHandler._is_filled_value(0) is True
221+
222+
def test_bool_is_filled(self):
223+
assert SaveHandler._is_filled_value(False) is True
224+
assert SaveHandler._is_filled_value(True) is True
225+
226+
def test_empty_container_is_empty(self):
227+
assert SaveHandler._is_filled_value([]) is False
228+
assert SaveHandler._is_filled_value({}) is False
229+
230+
def test_nested_all_null_is_empty(self):
231+
assert SaveHandler._is_filled_value({"a": None, "b": ""}) is False
232+
assert SaveHandler._is_filled_value([None, "", {"c": None}]) is False
233+
234+
def test_nested_with_value_is_filled(self):
235+
assert SaveHandler._is_filled_value({"a": None, "b": "x"}) is True
236+
assert SaveHandler._is_filled_value([None, "x"]) is True
237+

src/ContentProcessorAPI/app/routers/models/contentprocessor/claim_process.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ class Content_Process(EntityBase):
5353
mime_type: Optional[str] = Field(
5454
description="MIME type of the processed content file", default=None
5555
)
56-
entity_score: Optional[float] = Field(
57-
description="Score indicating the quality of entity extraction from the content. ``None`` means the score was not available (e.g. logprobs unavailable on reasoning models).",
58-
default=None,
56+
entity_score: float = Field(
57+
description="Score indicating the quality of entity extraction from the content. For Completed runs this is either the probabilistic confidence (when logprobs are available) or a structural completeness fallback (fraction of expected fields actually filled). Failed runs and genuinely empty extractions remain at ``0.0``.",
58+
default=0.0,
5959
)
60-
schema_score: Optional[float] = Field(
61-
description="Score indicating the quality of schema matching for the content. ``None`` means the score was not available.",
62-
default=None,
60+
schema_score: float = Field(
61+
description="Score indicating the quality of schema matching for the content. For Completed runs this is either the probabilistic above-threshold ratio or a structural completeness fallback. Failed runs remain at ``0.0``.",
62+
default=0.0,
6363
)
6464
status: Optional[str] = Field(
6565
description="Indicates the current status in the content processing pipeline",

0 commit comments

Comments
 (0)