Skip to content

Commit dd66aa7

Browse files
fix(scoring): show N/A instead of 0% for unavailable entity/schema scores
Root cause: When the evaluate step couldn't compute any per-field confidence (e.g. logprobs unavailable on reasoning models like gpt-5/o1/o3, or image-only flow with no Content Understanding signal), save_handler emitted entity_score=0.0, schema_score=0.0. These `0.0`s flowed through Cosmos -> API -> UI and rendered as `0%` (red), indistinguishable from a genuine zero confidence. Fix: Treat `total_evaluated_fields_count == 0` (or no comparison items) as *unavailable* and propagate `None` through the ContentProcessor, ContentProcessorAPI and ContentProcessorWorkflow models. The frontend percentage cell renderer now shows `N/A` for null/undefined and `0%` only for a genuine numeric zero. Files changed: - ContentProcessor: save_handler.py (extracted _derive_aggregate_scores helper) - ContentProcessor: content_process.py default scores -> None - ContentProcessorAPI: ContentProcess + Content_Process default scores -> None - ContentProcessorWorkflow: ContentProcessRecord + Content_Process default scores -> None - ContentProcessorWorkflow: document_process_executor preserves None instead of coercing to 0.0 - ContentProcessorWeb: ProcessQueueGridTypes types scores nullable; ProcessQueueGrid passes undefined for null/undefined; CustomCellRender renders `N/A` when valueText is null/undefined and only `...` while still processing Tests: - New: ContentProcessor/tests/unit/pipeline/test_save_handler_scores.py (5 cases: valid scores, missing per-field signal, no comparison items, genuine zero, all-fields-above-threshold) - Updated existing default-value tests in Workflow + src/tests to assert None - Added tests for explicit zero preservation and Failed status -> None
1 parent 3f193ba commit dd66aa7

15 files changed

Lines changed: 336 additions & 70 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] = 0.0
71-
min_extracted_entity_score: Optional[float] = 0.0
72-
schema_score: Optional[float] = 0.0
70+
entity_score: Optional[float] = None
71+
min_extracted_entity_score: Optional[float] = None
72+
schema_score: Optional[float] = None
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: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,20 +112,15 @@ 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+
# 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%".
122+
entity_score, schema_score, min_extracted_entity_score = (
123+
self._derive_aggregate_scores(evaluated_result)
129124
)
130125

131126
processed_result = ContentProcess(
@@ -143,11 +138,9 @@ def find_process_result(step_name: str):
143138
self._current_message_context.data_pipeline.pipeline_status.creation_time,
144139
"%Y-%m-%dT%H:%M:%S.%fZ",
145140
),
146-
entity_score=evaluated_result.confidence["overall_confidence"],
141+
entity_score=entity_score,
147142
schema_score=schema_score,
148-
min_extracted_entity_score=evaluated_result.confidence[
149-
"min_extracted_field_confidence"
150-
],
143+
min_extracted_entity_score=min_extracted_entity_score,
151144
prompt_tokens=evaluated_result.prompt_tokens,
152145
completion_tokens=evaluated_result.completion_tokens,
153146
target_schema=Schema.get_schema(
@@ -241,3 +234,41 @@ def _summarize_processed_time(self, step_results: list[StepResult]) -> str:
241234
# Format the total elapsed time as a string
242235
formatted_elapsed_time = f"{total_hours:02}:{total_minutes:02}:{total_seconds:02}.{total_milliseconds:03}"
243236
return formatted_elapsed_time
237+
238+
@staticmethod
239+
def _derive_aggregate_scores(
240+
evaluated_result: DataExtractionResult,
241+
) -> tuple[float | None, float | None, float | None]:
242+
"""Compute ``(entity_score, schema_score, min_extracted_entity_score)``.
243+
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%".
251+
252+
A genuine zero confidence (e.g. a model that emitted fields but
253+
every token had ``logprob == -inf``) is preserved verbatim.
254+
"""
255+
confidence = evaluated_result.confidence or {}
256+
total_evaluated_fields_count = confidence.get(
257+
"total_evaluated_fields_count", 0
258+
)
259+
comparison_items = (
260+
evaluated_result.comparison_result.items
261+
if evaluated_result.comparison_result is not None
262+
else []
263+
)
264+
if total_evaluated_fields_count == 0 or not comparison_items:
265+
return (None, None, None)
266+
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)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Tests for ``SaveHandler._derive_aggregate_scores``.
5+
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``
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from libs.pipeline.handlers.logics.evaluate_handler.comparison import (
16+
ExtractionComparisonData,
17+
ExtractionComparisonItem,
18+
)
19+
from libs.pipeline.handlers.logics.evaluate_handler.model import DataExtractionResult
20+
from libs.pipeline.handlers.save_handler import SaveHandler
21+
22+
23+
def _make_result(
24+
*,
25+
items: list[ExtractionComparisonItem],
26+
confidence: dict,
27+
) -> DataExtractionResult:
28+
return DataExtractionResult(
29+
extracted_result={},
30+
confidence=confidence,
31+
comparison_result=ExtractionComparisonData(items=items),
32+
prompt_tokens=0,
33+
completion_tokens=0,
34+
execution_time=0,
35+
)
36+
37+
38+
class TestDeriveAggregateScores:
39+
def test_valid_scores_flow_through(self):
40+
"""A normal evaluate-step result must produce numeric scores."""
41+
items = [
42+
ExtractionComparisonItem(
43+
Field="a", Extracted="x", Confidence="90.00%", IsAboveThreshold="True"
44+
),
45+
ExtractionComparisonItem(
46+
Field="b", Extracted="y", Confidence="80.00%", IsAboveThreshold="True"
47+
),
48+
ExtractionComparisonItem(
49+
Field="c", Extracted="z", Confidence="0.00%", IsAboveThreshold="False"
50+
),
51+
]
52+
confidence = {
53+
"total_evaluated_fields_count": 3,
54+
"overall_confidence": 0.567,
55+
"min_extracted_field_confidence": 0.0,
56+
"zero_confidence_fields_count": 1,
57+
}
58+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
59+
_make_result(items=items, confidence=confidence)
60+
)
61+
assert entity == 0.567
62+
# 2 of 3 fields above threshold → 0.667
63+
assert schema == round(2 / 3, 3)
64+
assert min_score == 0.0
65+
66+
def test_missing_per_field_signal_returns_none(self):
67+
"""Reasoning-model / image-only flow: no signal → ``None`` everywhere."""
68+
items: list[ExtractionComparisonItem] = []
69+
confidence = {
70+
"total_evaluated_fields_count": 0,
71+
"overall_confidence": 0.0,
72+
"min_extracted_field_confidence": 0.0,
73+
"zero_confidence_fields_count": 0,
74+
}
75+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
76+
_make_result(items=items, confidence=confidence)
77+
)
78+
assert entity is None
79+
assert schema is None
80+
assert min_score is None
81+
82+
def test_no_comparison_items_returns_none(self):
83+
"""Even if confidence claims fields exist, an empty comparison list is unknown."""
84+
confidence = {
85+
"total_evaluated_fields_count": 5,
86+
"overall_confidence": 0.9,
87+
"min_extracted_field_confidence": 0.5,
88+
"zero_confidence_fields_count": 0,
89+
}
90+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
91+
_make_result(items=[], confidence=confidence)
92+
)
93+
assert entity is None
94+
assert schema is None
95+
assert min_score is None
96+
97+
def test_genuine_zero_score_preserved(self):
98+
"""A real ``0`` confidence (e.g. all fields below threshold) must NOT become ``None``."""
99+
items = [
100+
ExtractionComparisonItem(
101+
Field="a", Extracted="x", Confidence="0.00%", IsAboveThreshold="False"
102+
),
103+
]
104+
confidence = {
105+
"total_evaluated_fields_count": 1,
106+
"overall_confidence": 0.0,
107+
"min_extracted_field_confidence": 0.0,
108+
"zero_confidence_fields_count": 1,
109+
}
110+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
111+
_make_result(items=items, confidence=confidence)
112+
)
113+
assert entity == 0.0
114+
assert schema == 0.0
115+
assert min_score == 0.0
116+
117+
def test_all_fields_above_threshold(self):
118+
items = [
119+
ExtractionComparisonItem(
120+
Field="a", Extracted="x", Confidence="95.00%", IsAboveThreshold="True"
121+
),
122+
ExtractionComparisonItem(
123+
Field="b", Extracted="y", Confidence="90.00%", IsAboveThreshold="True"
124+
),
125+
]
126+
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,
131+
}
132+
entity, schema, min_score = SaveHandler._derive_aggregate_scores(
133+
_make_result(items=items, confidence=confidence)
134+
)
135+
assert entity == 0.925
136+
assert schema == 1.0
137+
assert min_score == 0.9

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: float = Field(
57-
description="Score indicating the quality of entity extraction from the content",
58-
default=0.0,
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,
5959
)
60-
schema_score: float = Field(
61-
description="Score indicating the quality of schema matching for the content",
62-
default=0.0,
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,
6363
)
6464
status: Optional[str] = Field(
6565
description="Indicates the current status in the content processing pipeline",

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ class ContentProcess(BaseModel):
134134
)
135135
last_modified_by: Optional[str] = None
136136
status: Optional[str] = None
137-
entity_score: Optional[float] = 0.0
138-
min_extracted_entity_score: Optional[float] = 0.0
139-
schema_score: Optional[float] = 0.0
137+
entity_score: Optional[float] = None
138+
min_extracted_entity_score: Optional[float] = None
139+
schema_score: Optional[float] = None
140140
result: Optional[dict] = None
141141
confidence: Optional[dict] = None
142142
target_schema: Optional[Schema] = None

src/ContentProcessorWeb/src/Pages/DefaultPage/Components/ProcessQueueGrid/CustomCellRender.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ interface DeleteItem {
2929
interface CellRendererExtraProps {
3030
readonly txt?: string;
3131
readonly timeString?: string;
32-
readonly valueText?: string;
32+
/**
33+
* Stringified score value. ``undefined`` (or ``null``) means the score is
34+
* not available — the percentage renderer will show "N/A" instead of "0%"
35+
* to distinguish "unavailable" from a genuine zero.
36+
*/
37+
readonly valueText?: string | null;
3338
readonly status?: string;
3439
readonly lastModifiedBy?: string;
3540
readonly text?: string | number;
@@ -91,9 +96,31 @@ const CellRenderer: React.FC<CellRendererProps> = ({ type, props }) => {
9196
};
9297

9398
// Render for percentage
94-
const renderPercentage = (valueText: string, status: string) => {
99+
const renderPercentage = (valueText: string | null | undefined, status: string) => {
100+
// ``null``/``undefined``/empty string === score unavailable. Render an
101+
// explicit "N/A" so users can distinguish missing scores from a genuine
102+
// zero. (Backends emit ``None``/``null`` when, for example, logprobs were
103+
// unavailable on a reasoning model and confidence couldn't be computed.)
104+
if (valueText === null || valueText === undefined || valueText === '') {
105+
return (
106+
<div className="percentageContainer" title="Score not available">
107+
<span className="textClass">N/A</span>
108+
</div>
109+
);
110+
}
111+
95112
const decimalValue = Number(valueText);
96-
if (isNaN(decimalValue) || status !== 'Completed') {
113+
if (isNaN(decimalValue)) {
114+
return (
115+
<div className="percentageContainer" title="Score not available">
116+
<span className="textClass">N/A</span>
117+
</div>
118+
);
119+
}
120+
121+
// Score is numeric (including a genuine 0): only show "..." while the
122+
// document is still being processed.
123+
if (status !== 'Completed') {
97124
return <div className="percentageContainer"><span className="textClass">...</span></div>;
98125
}
99126

@@ -124,7 +151,7 @@ const CellRenderer: React.FC<CellRendererProps> = ({ type, props }) => {
124151
};
125152

126153
// Render for schema score
127-
const calculateSchemaScore = (valueText: string, lastModifiedBy: string, status: string) => {
154+
const calculateSchemaScore = (valueText: string | null | undefined, lastModifiedBy: string, status: string) => {
128155
if (lastModifiedBy === 'user') {
129156
return (
130157
<div className="percentageContainer">
@@ -186,9 +213,9 @@ const CellRenderer: React.FC<CellRendererProps> = ({ type, props }) => {
186213
case 'processTime':
187214
return renderProcessTimeInSeconds(timeString || '');
188215
case 'percentage':
189-
return renderPercentage(valueText || '', status || '');
216+
return renderPercentage(valueText, status || '');
190217
case 'schemaScore':
191-
return calculateSchemaScore(valueText || '', lastModifiedBy || '', status || '');
218+
return calculateSchemaScore(valueText, lastModifiedBy || '', status || '');
192219
case 'text':
193220
return renderText(text ?? '', 'center');
194221
case 'date':

src/ContentProcessorWeb/src/Pages/DefaultPage/Components/ProcessQueueGrid/ProcessQueueGrid.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,11 @@ const ProcessQueueGrid: React.FC<GridComponentProps> = () => {
373373
<CustomCellRender
374374
type="percentage"
375375
props={{
376-
valueText: doc.entity_score.toString(),
376+
valueText:
377+
doc.entity_score === null ||
378+
doc.entity_score === undefined
379+
? undefined
380+
: doc.entity_score.toString(),
377381
status: doc.status,
378382
}}
379383
/>
@@ -382,7 +386,11 @@ const ProcessQueueGrid: React.FC<GridComponentProps> = () => {
382386
<CustomCellRender
383387
type="percentage"
384388
props={{
385-
valueText: doc.schema_score.toString(),
389+
valueText:
390+
doc.schema_score === null ||
391+
doc.schema_score === undefined
392+
? undefined
393+
: doc.schema_score.toString(),
386394
status: doc.status,
387395
}}
388396
/>

src/ContentProcessorWeb/src/Pages/DefaultPage/Components/ProcessQueueGrid/ProcessQueueGridTypes.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,23 @@ export interface ProcessedDocument {
1717
readonly file_name: string;
1818
/** MIME type of the document. */
1919
readonly mime_type: string;
20-
/** Entity extraction confidence score (0–1). */
21-
readonly entity_score: number;
22-
/** Schema compliance score (0–1). */
23-
readonly schema_score: number;
20+
/**
21+
* Entity extraction confidence score in the range 0–1.
22+
*
23+
* ``null``/``undefined`` means the score was not produced by the backend
24+
* (for example: logprobs were unavailable on a reasoning model, or the
25+
* pipeline didn't reach the evaluate step). In that case the UI shows
26+
* "N/A" rather than a misleading "0%". A genuine numeric ``0`` is still
27+
* rendered as ``0%``.
28+
*/
29+
readonly entity_score: number | null | undefined;
30+
/**
31+
* Schema compliance score in the range 0–1.
32+
*
33+
* ``null``/``undefined`` means the score was not produced. See
34+
* {@link entity_score} for rendering semantics.
35+
*/
36+
readonly schema_score: number | null | undefined;
2437
/** Current processing status. */
2538
readonly status: string;
2639
/** Duration string for processing time (HH:MM:SS). */

0 commit comments

Comments
 (0)