Skip to content

Commit 3834769

Browse files
authored
Merge pull request #199 from bcgov/AI-1445
AI-1445 More OCR Results Views
2 parents 3341fac + 3447b11 commit 3834769

3 files changed

Lines changed: 202 additions & 122 deletions

File tree

apps/frontend/src/components/document/DocumentViewerModal.tsx

Lines changed: 6 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ import {
77
} from "@tabler/icons-react";
88
import { useEffect, useState } from "react";
99
import { useDocumentOcr } from "../../data/hooks/useDocumentOcr";
10-
import { Document, DocumentField, ExtractedFields } from "../../shared/types";
10+
import { Document } from "../../shared/types";
1111
import {
1212
ActionIcon,
1313
Alert,
1414
Badge,
15-
DataTable,
1615
Group,
1716
Loader,
1817
Modal,
@@ -25,7 +24,7 @@ import {
2524
} from "../../ui";
2625
import { DocumentValidation } from "./DocumentValidation";
2726
import { DocumentViewer } from "./DocumentViewer";
28-
import { ExtractedTextView } from "./ExtractedTextView";
27+
import OcrResults from "./OcrResults";
2928

3029
function formatFileSize(bytes: number): string {
3130
if (bytes < 1024 * 1024) {
@@ -45,87 +44,6 @@ interface DocumentViewerModalProps {
4544
onClose: () => void;
4645
}
4746

48-
function getFieldDisplayValue(field: DocumentField): string {
49-
if (field.valueSelectionMark !== undefined) {
50-
return field.valueSelectionMark === "selected"
51-
? "☑ Selected"
52-
: "☐ Unselected";
53-
}
54-
if (field.valueNumber !== undefined) {
55-
return field.valueNumber.toString();
56-
}
57-
if (field.valueDate !== undefined) {
58-
return field.valueDate;
59-
}
60-
if (field.valueString !== undefined) {
61-
return field.valueString;
62-
}
63-
return field.content || "—";
64-
}
65-
66-
function ExtractedFieldsTable({ fields }: { fields: ExtractedFields }) {
67-
const entries = Object.entries(fields);
68-
69-
if (entries.length === 0) {
70-
return <Text c="dimmed">No fields extracted.</Text>;
71-
}
72-
73-
return (
74-
<DataTable
75-
striped
76-
highlightOnHover
77-
withTableBorder
78-
style={{
79-
tableLayout: "fixed",
80-
width: "100%",
81-
marginBottom: "2rem",
82-
}}
83-
>
84-
<DataTable.Thead>
85-
<DataTable.Tr>
86-
<DataTable.Th style={{ width: "25%" }}>Field</DataTable.Th>
87-
<DataTable.Th style={{ width: "45%" }}>Value</DataTable.Th>
88-
<DataTable.Th style={{ width: "15%" }}>Type</DataTable.Th>
89-
<DataTable.Th style={{ width: "15%" }}>Confidence</DataTable.Th>
90-
</DataTable.Tr>
91-
</DataTable.Thead>
92-
<DataTable.Tbody>
93-
{entries.map(([name, field]) => (
94-
<DataTable.Tr key={name}>
95-
<DataTable.Td style={{ wordBreak: "break-word" }}>
96-
<Text size="sm" fw={500}>
97-
{name}
98-
</Text>
99-
</DataTable.Td>
100-
<DataTable.Td style={{ wordBreak: "break-word" }}>
101-
<Text size="sm">{getFieldDisplayValue(field)}</Text>
102-
</DataTable.Td>
103-
<DataTable.Td>
104-
<Badge size="xs" variant="light">
105-
{field.type}
106-
</Badge>
107-
</DataTable.Td>
108-
<DataTable.Td>
109-
<Text
110-
size="sm"
111-
c={
112-
field.confidence >= 0.9
113-
? "green"
114-
: field.confidence >= 0.7
115-
? "yellow"
116-
: "red"
117-
}
118-
>
119-
{(field.confidence * 100).toFixed(1)}%
120-
</Text>
121-
</DataTable.Td>
122-
</DataTable.Tr>
123-
))}
124-
</DataTable.Tbody>
125-
</DataTable>
126-
);
127-
}
128-
12947
export function DocumentViewerModal({
13048
document,
13149
opened,
@@ -393,7 +311,7 @@ export function DocumentViewerModal({
393311
{imageUrl ? (
394312
<DocumentViewer
395313
imageUrl={imageUrl}
396-
extractedFields={ocrResult?.ocr_result?.keyValuePairs}
314+
extractedFields={ocr?.keyValuePairs}
397315
pageNumber={1}
398316
showOverlays={showOverlays}
399317
rotation={rotation}
@@ -416,7 +334,6 @@ export function DocumentViewerModal({
416334
</div>
417335
) : null}
418336
</Tabs.Panel>
419-
420337
{hasOcrData && (
421338
<Tabs.Panel
422339
value="ocr-results"
@@ -427,40 +344,7 @@ export function DocumentViewerModal({
427344
flexDirection: "column",
428345
}}
429346
>
430-
<div
431-
style={{
432-
flex: 1,
433-
minHeight: 0,
434-
overflow: "auto",
435-
padding: "1rem",
436-
paddingBottom: "3rem",
437-
}}
438-
>
439-
<Stack gap="lg">
440-
{/* Extracted text (markdown) is the primary representation
441-
when present; key-value fields follow below. */}
442-
{hasOcrText && ocr?.content && (
443-
<div>
444-
{hasKeyValues && (
445-
<Title order={5} mb="xs">
446-
Extracted Text
447-
</Title>
448-
)}
449-
<ExtractedTextView content={ocr.content} />
450-
</div>
451-
)}
452-
{hasKeyValues && ocr?.keyValuePairs && (
453-
<div>
454-
{hasOcrText && (
455-
<Title order={5} mb="xs">
456-
Key-Value Fields
457-
</Title>
458-
)}
459-
<ExtractedFieldsTable fields={ocr.keyValuePairs} />
460-
</div>
461-
)}
462-
</Stack>
463-
</div>
347+
<OcrResults ocr={ocr ?? null} />
464348
</Tabs.Panel>
465349
)}
466350
{(document?.status === "awaiting_review" ||
@@ -483,10 +367,10 @@ export function DocumentViewerModal({
483367
paddingBottom: "3rem",
484368
}}
485369
>
486-
{ocrResult?.ocr_result ? (
370+
{ocr ? (
487371
<DocumentValidation
488372
document={document}
489-
ocrResult={ocrResult.ocr_result}
373+
ocrResult={ocr}
490374
onValidationComplete={() => {
491375
// Refresh the document list and close modal after a short delay
492376
setTimeout(() => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { DocumentField, ExtractedFields } from "@/shared/types";
2+
import { Badge, DataTable, Text } from "@/ui";
3+
4+
function getFieldDisplayValue(field: DocumentField): string {
5+
if (field.valueSelectionMark !== undefined) {
6+
return field.valueSelectionMark === "selected"
7+
? "☑ Selected"
8+
: "☐ Unselected";
9+
}
10+
if (field.valueNumber !== undefined) {
11+
return field.valueNumber.toString();
12+
}
13+
if (field.valueDate !== undefined) {
14+
return field.valueDate;
15+
}
16+
if (field.valueString !== undefined) {
17+
return field.valueString;
18+
}
19+
return field.content || "—";
20+
}
21+
22+
const ExtractedFieldsTable = ({ fields }: { fields: ExtractedFields }) => {
23+
const entries = Object.entries(fields);
24+
25+
if (entries.length === 0) {
26+
return <Text c="dimmed">No fields extracted.</Text>;
27+
}
28+
29+
return (
30+
<DataTable
31+
striped
32+
highlightOnHover
33+
withTableBorder
34+
style={{
35+
tableLayout: "fixed",
36+
width: "100%",
37+
marginBottom: "2rem",
38+
}}
39+
>
40+
<DataTable.Thead>
41+
<DataTable.Tr>
42+
<DataTable.Th style={{ width: "25%" }}>Field</DataTable.Th>
43+
<DataTable.Th style={{ width: "45%" }}>Value</DataTable.Th>
44+
<DataTable.Th style={{ width: "15%" }}>Type</DataTable.Th>
45+
<DataTable.Th style={{ width: "15%" }}>Confidence</DataTable.Th>
46+
</DataTable.Tr>
47+
</DataTable.Thead>
48+
<DataTable.Tbody>
49+
{entries.map(([name, field]) => (
50+
<DataTable.Tr key={name}>
51+
<DataTable.Td style={{ wordBreak: "break-word" }}>
52+
<Text size="sm" fw={500}>
53+
{name}
54+
</Text>
55+
</DataTable.Td>
56+
<DataTable.Td style={{ wordBreak: "break-word" }}>
57+
<Text size="sm">{getFieldDisplayValue(field)}</Text>
58+
</DataTable.Td>
59+
<DataTable.Td>
60+
<Badge size="xs" variant="light">
61+
{field.type}
62+
</Badge>
63+
</DataTable.Td>
64+
<DataTable.Td>
65+
<Text
66+
size="sm"
67+
c={
68+
field.confidence >= 0.9
69+
? "green"
70+
: field.confidence >= 0.7
71+
? "yellow"
72+
: "red"
73+
}
74+
>
75+
{(field.confidence * 100).toFixed(1)}%
76+
</Text>
77+
</DataTable.Td>
78+
</DataTable.Tr>
79+
))}
80+
</DataTable.Tbody>
81+
</DataTable>
82+
);
83+
};
84+
85+
export default ExtractedFieldsTable;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
ToggleButton,
3+
ToggleButtonGroup,
4+
} from "@bcgov/design-system-react-components";
5+
import { Code, Text } from "@mantine/core";
6+
import { useState } from "react";
7+
import { OcrResult } from "@/shared/types";
8+
import ExtractedFieldsTable from "./ExtractedFieldsTable";
9+
import { ExtractedTextView } from "./ExtractedTextView";
10+
11+
interface OcrResultsProps {
12+
ocr: OcrResult | null;
13+
}
14+
15+
enum ToggleStates {
16+
EXTRACTED,
17+
TEXT,
18+
JSON,
19+
}
20+
21+
const OcrResults = (props: OcrResultsProps) => {
22+
const { ocr } = props;
23+
const hasKeyValues = !!ocr?.keyValuePairs;
24+
const hasText = !!(ocr?.content?.markdown || ocr?.content?.text);
25+
26+
// Default to whichever view has data; field-extraction models lead with the
27+
// table, read/layout models lead with the text.
28+
const [toggleId, setToggleId] = useState(
29+
hasKeyValues ? ToggleStates.EXTRACTED : ToggleStates.TEXT,
30+
);
31+
32+
if (ocr == null) {
33+
return <Text c="dimmed">No OCR results available.</Text>;
34+
}
35+
36+
const renderContent = () => {
37+
switch (toggleId) {
38+
case ToggleStates.EXTRACTED:
39+
return <ExtractedFieldsTable fields={ocr.keyValuePairs ?? {}} />;
40+
case ToggleStates.TEXT:
41+
// Delegate to ExtractedTextView so Azure layout markdown renders (with
42+
// a rendered/raw sub-toggle) instead of being dumped as plain text.
43+
return ocr.content ? (
44+
<ExtractedTextView content={ocr.content} />
45+
) : (
46+
<Text c="dimmed">No extracted text available.</Text>
47+
);
48+
case ToggleStates.JSON:
49+
return (
50+
<Code
51+
block
52+
color="dark.6"
53+
c="white"
54+
style={{
55+
borderRadius: "0.5em",
56+
}}
57+
>
58+
{JSON.stringify(ocr, undefined, 2)}
59+
</Code>
60+
);
61+
default:
62+
return (
63+
<Text>
64+
Invalid results selection. Use the selector above to choose a valid
65+
results view.
66+
</Text>
67+
);
68+
}
69+
};
70+
71+
return (
72+
<>
73+
<ToggleButtonGroup
74+
orientation="horizontal"
75+
selectionMode="single"
76+
disallowEmptySelection
77+
size="small"
78+
selectedKeys={[toggleId]}
79+
onSelectionChange={(set) => {
80+
// Set contains all selected ids, but we only use single select.
81+
setToggleId(set.values().next().value as ToggleStates);
82+
}}
83+
style={{
84+
flex: "0 0 auto",
85+
margin: "0.5em auto",
86+
}}
87+
>
88+
<ToggleButton id={ToggleStates.EXTRACTED} isDisabled={!hasKeyValues}>
89+
Extracted
90+
</ToggleButton>
91+
<ToggleButton id={ToggleStates.TEXT} isDisabled={!hasText}>
92+
Text
93+
</ToggleButton>
94+
<ToggleButton id={ToggleStates.JSON}>JSON</ToggleButton>
95+
</ToggleButtonGroup>
96+
<div
97+
style={{
98+
flex: 1,
99+
minHeight: 0,
100+
overflow: "auto",
101+
padding: "1rem",
102+
paddingBottom: "3rem",
103+
}}
104+
>
105+
{renderContent()}
106+
</div>
107+
</>
108+
);
109+
};
110+
111+
export default OcrResults;

0 commit comments

Comments
 (0)