indico_toolkit 7.2.1__tar.gz → 7.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/PKG-INFO +2 -2
  2. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/__init__.py +1 -1
  3. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/__init__.py +11 -10
  4. {indico_toolkit-7.2.1/indico_toolkit/results/predictions → indico_toolkit-7.2.3/indico_toolkit/etloutput}/box.py +27 -6
  5. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/cell.py +30 -3
  6. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/etloutput.py +60 -32
  7. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/range.py +19 -1
  8. {indico_toolkit-7.2.1/indico_toolkit/results/predictions → indico_toolkit-7.2.3/indico_toolkit/etloutput}/span.py +23 -6
  9. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/table.py +41 -2
  10. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/etloutput/token.py +18 -2
  11. indico_toolkit-7.2.3/indico_toolkit/etloutput/utils.py +69 -0
  12. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/polling/queries.py +17 -8
  13. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/__init__.py +1 -6
  14. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/normalization.py +8 -4
  15. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictionlist.py +43 -6
  16. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/__init__.py +13 -8
  17. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/citation.py +3 -6
  18. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/classification.py +2 -4
  19. indico_toolkit-7.2.3/indico_toolkit/results/predictions/documentextraction.py +205 -0
  20. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/formextraction.py +3 -5
  21. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/group.py +1 -4
  22. indico_toolkit-7.2.3/indico_toolkit/results/predictions/prediction.py +71 -0
  23. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/summarization.py +21 -14
  24. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/unbundling.py +13 -4
  25. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/result.py +13 -3
  26. indico_toolkit-7.2.3/indico_toolkit/results/utils.py +42 -0
  27. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/retry.py +10 -11
  28. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/pyproject.toml +2 -2
  29. indico_toolkit-7.2.1/indico_toolkit/etloutput/errors.py +0 -16
  30. indico_toolkit-7.2.1/indico_toolkit/results/errors.py +0 -4
  31. indico_toolkit-7.2.1/indico_toolkit/results/predictions/documentextraction.py +0 -100
  32. indico_toolkit-7.2.1/indico_toolkit/results/predictions/prediction.py +0 -35
  33. indico_toolkit-7.2.1/indico_toolkit/results/utils.py +0 -101
  34. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/LICENSE +0 -0
  35. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/README.md +0 -0
  36. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/__init__.py +0 -0
  37. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/association.py +0 -0
  38. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/extracted_tokens.py +0 -0
  39. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/line_items.py +0 -0
  40. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/positioning.py +0 -0
  41. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/association/splitting.py +0 -0
  42. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/auto_review/__init__.py +0 -0
  43. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/auto_review/auto_review_functions.py +0 -0
  44. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/auto_review/auto_reviewer.py +0 -0
  45. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/client.py +0 -0
  46. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/errors.py +0 -0
  47. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/indico_wrapper/__init__.py +0 -0
  48. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/indico_wrapper/download.py +0 -0
  49. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/indico_wrapper/indico_wrapper.py +0 -0
  50. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/indico_wrapper/modelop.py +0 -0
  51. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/indico_wrapper/workflow.py +0 -0
  52. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/metrics/__init__.py +0 -0
  53. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/metrics/compare_ground_truth.py +0 -0
  54. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/metrics/compare_models.py +0 -0
  55. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/metrics/metrics.py +0 -0
  56. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/metrics/plotting.py +0 -0
  57. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/ocr/__init__.py +0 -0
  58. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/ocr/ondoc_object.py +0 -0
  59. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/ocr/standard_object.py +0 -0
  60. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/pipelines/__init__.py +0 -0
  61. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/pipelines/file_processing.py +0 -0
  62. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/polling/__init__.py +0 -0
  63. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/polling/autoreview.py +0 -0
  64. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/polling/downstream.py +0 -0
  65. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/py.typed +0 -0
  66. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/document.py +0 -0
  67. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/predictions/extraction.py +0 -0
  68. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/review.py +0 -0
  69. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/results/task.py +0 -0
  70. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/snapshots/__init__.py +0 -0
  71. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/snapshots/snapshot.py +0 -0
  72. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/structure/__init__.py +0 -0
  73. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/structure/create_structure.py +0 -0
  74. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/structure/queries.py +0 -0
  75. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/structure/utils.py +0 -0
  76. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/types/__init__.py +0 -0
  77. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/types/classification.py +0 -0
  78. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/types/extractions.py +0 -0
  79. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/types/predictions.py +0 -0
  80. {indico_toolkit-7.2.1 → indico_toolkit-7.2.3}/indico_toolkit/types/workflow_object.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: indico-toolkit
3
- Version: 7.2.1
3
+ Version: 7.2.3
4
4
  Summary: Classes, functions, and abstractions for Indico IPA
5
5
  License: MIT
6
6
  Author: Indico Solutions Engineering
@@ -18,7 +18,7 @@ Provides-Extra: examples
18
18
  Provides-Extra: metrics
19
19
  Provides-Extra: predictions
20
20
  Provides-Extra: snapshots
21
- Requires-Dist: indico-client (>=6.14.0,<7.0.0)
21
+ Requires-Dist: indico-client (>=6.14.0,<8.0.0)
22
22
  Requires-Dist: pandas (>=2.2.3,<3.0.0) ; extra == "all"
23
23
  Requires-Dist: pandas (>=2.2.3,<3.0.0) ; extra == "downloads"
24
24
  Requires-Dist: pandas (>=2.2.3,<3.0.0) ; extra == "examples"
@@ -21,4 +21,4 @@ __all__ = (
21
21
  "ToolkitStaggeredLoopError",
22
22
  "ToolkitStatusError",
23
23
  )
24
- __version__ = "7.2.1"
24
+ __version__ = "7.2.3"
@@ -1,13 +1,13 @@
1
1
  from typing import TYPE_CHECKING, TypeAlias, TypeVar
2
2
 
3
- from ..results import NULL_BOX, NULL_SPAN, Box, Span
4
- from ..results.utils import get, has, json_loaded, str_decoded
5
- from .cell import Cell, CellType
6
- from .errors import EtlOutputError, TableCellNotFoundError, TokenNotFoundError
3
+ from .box import NULL_BOX, Box
4
+ from .cell import NULL_CELL, Cell, CellType
7
5
  from .etloutput import EtlOutput
8
- from .range import Range
9
- from .table import Table
10
- from .token import Token
6
+ from .range import NULL_RANGE, Range
7
+ from .span import NULL_SPAN, Span
8
+ from .table import NULL_TABLE, Table
9
+ from .token import NULL_TOKEN, Token
10
+ from .utils import get, has, json_loaded, str_decoded
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from collections.abc import Awaitable, Callable
@@ -17,17 +17,18 @@ __all__ = (
17
17
  "Cell",
18
18
  "CellType",
19
19
  "EtlOutput",
20
- "EtlOutputError",
21
20
  "load",
22
21
  "load_async",
23
22
  "NULL_BOX",
23
+ "NULL_CELL",
24
+ "NULL_RANGE",
24
25
  "NULL_SPAN",
26
+ "NULL_TABLE",
27
+ "NULL_TOKEN",
25
28
  "Range",
26
29
  "Span",
27
30
  "Table",
28
- "TableCellNotFoundError",
29
31
  "Token",
30
- "TokenNotFoundError",
31
32
  )
32
33
 
33
34
  Loadable: TypeAlias = "dict[str, object] | list[object] | str | bytes"
@@ -1,10 +1,7 @@
1
1
  from dataclasses import dataclass
2
- from typing import TYPE_CHECKING
2
+ from typing import Final
3
3
 
4
- from ..utils import get
5
-
6
- if TYPE_CHECKING:
7
- from typing import Final
4
+ from .utils import get
8
5
 
9
6
 
10
7
  @dataclass(frozen=True)
@@ -18,6 +15,30 @@ class Box:
18
15
  def __bool__(self) -> bool:
19
16
  return self != NULL_BOX
20
17
 
18
+ def __and__(self, other: "Box") -> "Box":
19
+ """
20
+ Return a new `Box` for the overlap between `self` and `other`
21
+ or `NULL_BOX` if they don't overlap.
22
+
23
+ Supports set-like `extraction.box & cell.box` syntax.
24
+ """
25
+ if (
26
+ self.page != other.page
27
+ or self.bottom <= other.top # `self` is above `other`
28
+ or self.top >= other.bottom # `self` is below `other`
29
+ or self.right <= other.left # `self` is to the left of `other`
30
+ or self.left >= other.right # `self` is to the right of `other`
31
+ ):
32
+ return NULL_BOX
33
+ else:
34
+ return Box(
35
+ page=self.page,
36
+ top=max(self.top, other.top),
37
+ left=max(self.left, other.left),
38
+ right=min(self.right, other.right),
39
+ bottom=min(self.bottom, other.bottom),
40
+ )
41
+
21
42
  def __lt__(self, other: "Box") -> bool:
22
43
  """
23
44
  Bounding boxes are sorted with vertical hysteresis. Those on the same line are
@@ -58,4 +79,4 @@ class Box:
58
79
  # object rather than using `None` or raising an error. This lets you e.g. sort by the
59
80
  # `box` attribute without having to constantly check for `None`, while still allowing
60
81
  # you do a "None check" with `bool(extraction.box)` or `extraction.box == NULL_BOX`.
61
- NULL_BOX: "Final" = Box(page=0, top=0, left=0, right=0, bottom=0)
82
+ NULL_BOX: Final = Box(page=0, top=0, left=0, right=0, bottom=0)
@@ -1,9 +1,11 @@
1
1
  from dataclasses import dataclass
2
2
  from enum import Enum
3
+ from typing import Final
3
4
 
4
- from ..results import NULL_SPAN, Box, Span
5
- from ..results.utils import get
6
- from .range import Range
5
+ from .box import NULL_BOX, Box
6
+ from .range import NULL_RANGE, Range
7
+ from .span import NULL_SPAN, Span
8
+ from .utils import get
7
9
 
8
10
 
9
11
  class CellType(Enum):
@@ -19,6 +21,18 @@ class Cell:
19
21
  range: Range
20
22
  spans: "tuple[Span, ...]"
21
23
 
24
+ def __bool__(self) -> bool:
25
+ return self != NULL_CELL
26
+
27
+ def __hash__(self) -> int:
28
+ """
29
+ Uniquely identify cells by hashing their bounding box and spans.
30
+
31
+ This is small speedup for `.groupby(attrgetter("cell"))` compared to
32
+ dataclasses's default __hash__ implementation.
33
+ """
34
+ return hash((self.box, self.spans))
35
+
22
36
  @property
23
37
  def span(self) -> Span:
24
38
  """
@@ -45,3 +59,16 @@ class Cell:
45
59
  range=Range.from_dict(cell),
46
60
  spans=tuple(map(Span.from_dict, get(cell, list, "doc_offsets"))),
47
61
  )
62
+
63
+
64
+ # It's more ergonomic to represent the lack of cells with a special null cell object
65
+ # rather than using `None` or raising an error. This lets you e.g. sort by the `cell`
66
+ # attribute without having to constantly check for `None`, while still allowing you do
67
+ # a "None check" with `bool(extraction.cell)` or `extraction.cell == NULL_CELL`.
68
+ NULL_CELL: Final = Cell(
69
+ type=CellType.CONTENT,
70
+ text="",
71
+ box=NULL_BOX,
72
+ range=NULL_RANGE,
73
+ spans=tuple(),
74
+ )
@@ -1,18 +1,20 @@
1
1
  import itertools
2
2
  from bisect import bisect_left, bisect_right
3
+ from collections import namedtuple
3
4
  from dataclasses import dataclass
5
+ from functools import cached_property
4
6
  from operator import attrgetter
5
7
  from typing import TYPE_CHECKING
6
8
 
7
- from ..results import Box, Span
8
- from .errors import TableCellNotFoundError, TokenNotFoundError
9
+ from .box import Box
9
10
  from .table import Table
10
- from .token import Token
11
+ from .token import NULL_TOKEN, Token
11
12
 
12
13
  if TYPE_CHECKING:
13
- from collections.abc import Iterable
14
+ from collections.abc import Iterable, Iterator
14
15
 
15
16
  from .cell import Cell
17
+ from .span import Span
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -54,18 +56,19 @@ class EtlOutput:
54
56
  tables_on_page=table_pages,
55
57
  )
56
58
 
57
- def token_for(self, span: Span) -> Token:
59
+ def token_for(self, span: "Span") -> Token:
58
60
  """
59
- Return a `Token` that contains every character from `span`.
60
- Raise `TokenNotFoundError` if one can't be produced.
61
+ Return a `Token` that contains every character from `span`
62
+ or `NULL_TOKEN` if one doesn't exist.
61
63
  """
62
64
  try:
63
65
  tokens = self.tokens_on_page[span.page]
64
66
  first = bisect_right(tokens, span.start, key=attrgetter("span.end"))
65
67
  last = bisect_left(tokens, span.end, lo=first, key=attrgetter("span.start"))
66
68
  tokens = tokens[first:last]
67
- except (IndexError, ValueError) as error:
68
- raise TokenNotFoundError(f"no token contains {span!r}") from error
69
+ assert tokens
70
+ except (AssertionError, IndexError, ValueError):
71
+ return NULL_TOKEN
69
72
 
70
73
  return Token(
71
74
  text=self.text[span.slice],
@@ -79,28 +82,53 @@ class EtlOutput:
79
82
  span=span,
80
83
  )
81
84
 
82
- def table_cell_for(self, token: Token) -> "tuple[Table, Cell]":
85
+ _TableCellSpan = namedtuple("_TableCellSpan", ["table", "cell", "span"])
86
+
87
+ @cached_property
88
+ def _table_cell_spans_on_page(self) -> "tuple[tuple[_TableCellSpan, ...], ...]":
89
+ """
90
+ Order table cells on each page by their spans such that they can be bisected.
91
+ """
92
+ return tuple(
93
+ tuple(
94
+ sorted(
95
+ (
96
+ self._TableCellSpan(table, cell, span)
97
+ for table in page_tables
98
+ for cell in table.cells
99
+ for span in cell.spans
100
+ if span
101
+ ),
102
+ key=attrgetter("span"),
103
+ )
104
+ )
105
+ for page_tables in self.tables_on_page
106
+ )
107
+
108
+ def table_cells_for(self, span: "Span") -> "Iterator[tuple[Table, Cell]]":
83
109
  """
84
- Return the `Table` and `Cell` that contain the midpoint of `token`.
85
- Raise `TableCellNotFoundError` if it's not inside a table cell.
110
+ Yield the table cells that overlap with `span`.
111
+
112
+ Note: a single span may overlap the same cell multiple times causing it to be
113
+ yielded multiple times. Deduplication in `DocumentExtraction.table_cells`
114
+ accounts for this when OCR is assigned with `PredictionList.assign_ocr()`.
86
115
  """
87
- token_vmid = (token.box.top + token.box.bottom) // 2
88
- token_hmid = (token.box.left + token.box.right) // 2
89
-
90
- for table in self.tables_on_page[token.box.page]:
91
- if (
92
- (table.box.top <= token_vmid <= table.box.bottom) and
93
- (table.box.left <= token_hmid <= table.box.right)
94
- ): # fmt: skip
95
- break
96
- else:
97
- raise TableCellNotFoundError(f"no table contains {token!r}")
98
-
99
- for cell in table.cells:
100
- if (
101
- (cell.box.top <= token_vmid <= cell.box.bottom) and
102
- (cell.box.left <= token_hmid <= cell.box.right)
103
- ): # fmt: skip
104
- return table, cell
105
- else:
106
- raise TableCellNotFoundError(f"no cell contains {token!r}")
116
+ try:
117
+ page_table_cell_spans = self._table_cell_spans_on_page[span.page]
118
+ first = bisect_right(
119
+ page_table_cell_spans,
120
+ span.start,
121
+ key=attrgetter("span.end"),
122
+ )
123
+ last = bisect_left(
124
+ page_table_cell_spans,
125
+ span.end,
126
+ lo=first,
127
+ key=attrgetter("span.start"),
128
+ )
129
+ table_cell_spans = page_table_cell_spans[first:last]
130
+ except (IndexError, ValueError):
131
+ table_cell_spans = tuple()
132
+
133
+ for table, cell, span in table_cell_spans:
134
+ yield table, cell
@@ -1,6 +1,7 @@
1
1
  from dataclasses import dataclass
2
+ from typing import Final
2
3
 
3
- from ..results.utils import get
4
+ from .utils import get
4
5
 
5
6
 
6
7
  @dataclass(order=True, frozen=True)
@@ -12,6 +13,9 @@ class Range:
12
13
  rows: "tuple[int, ...]"
13
14
  columns: "tuple[int, ...]"
14
15
 
16
+ def __bool__(self) -> bool:
17
+ return self != NULL_RANGE
18
+
15
19
  @staticmethod
16
20
  def from_dict(cell: object) -> "Range":
17
21
  """
@@ -28,3 +32,17 @@ class Range:
28
32
  rows=tuple(rows),
29
33
  columns=tuple(columns),
30
34
  )
35
+
36
+
37
+ # It's more ergonomic to represent the lack of ranges with a special null range object
38
+ # rather than using `None` or raising an error. This lets you e.g. sort by the `range`
39
+ # attribute without having to constantly check for `None`, while still allowing you do
40
+ # a "None check" with `bool(cell.range)` or `cell.range == NULL_RANGE`.
41
+ NULL_RANGE: Final = Range(
42
+ row=0,
43
+ column=0,
44
+ rowspan=0,
45
+ columnspan=0,
46
+ rows=tuple(),
47
+ columns=tuple(),
48
+ )
@@ -1,10 +1,7 @@
1
1
  from dataclasses import dataclass
2
- from typing import TYPE_CHECKING
2
+ from typing import Any, Final
3
3
 
4
- from ..utils import get
5
-
6
- if TYPE_CHECKING:
7
- from typing import Any, Final
4
+ from .utils import get
8
5
 
9
6
 
10
7
  @dataclass(order=True, frozen=True)
@@ -20,6 +17,26 @@ class Span:
20
17
  def __bool__(self) -> bool:
21
18
  return self != NULL_SPAN
22
19
 
20
+ def __and__(self, other: "Span") -> "Span":
21
+ """
22
+ Return a new `Span` for the overlap between `self` and `other`
23
+ or `NULL_SPAN` if they don't overlap.
24
+
25
+ Supports set-like `extraction.span & cell.span` syntax.
26
+ """
27
+ if (
28
+ self.page != other.page
29
+ or self.end <= other.start # `self` is to the left of `other`
30
+ or self.start >= other.end # `self` is to the right of `other`
31
+ ):
32
+ return NULL_SPAN
33
+ else:
34
+ return Span(
35
+ page=self.page,
36
+ start=max(self.start, other.start),
37
+ end=min(self.end, other.end),
38
+ )
39
+
23
40
  @staticmethod
24
41
  def from_dict(span: object) -> "Span":
25
42
  return Span(
@@ -40,4 +57,4 @@ class Span:
40
57
  # rather than using `None` or raising an error. This lets you e.g. sort by the `span`
41
58
  # attribute without having to constantly check for `None`, while still allowing you do
42
59
  # a "None check" with `bool(extraction.span)` or `extraction.span == NULL_SPAN`.
43
- NULL_SPAN: "Final" = Span(page=0, start=0, end=0)
60
+ NULL_SPAN: Final = Span(page=0, start=0, end=0)
@@ -1,18 +1,40 @@
1
1
  from dataclasses import dataclass
2
2
  from operator import attrgetter
3
+ from typing import Final
3
4
 
4
- from ..results import Box
5
- from ..results.utils import get
5
+ from .box import NULL_BOX, Box
6
6
  from .cell import Cell
7
+ from .span import NULL_SPAN, Span
8
+ from .utils import get
7
9
 
8
10
 
9
11
  @dataclass(frozen=True)
10
12
  class Table:
11
13
  box: Box
14
+ spans: "tuple[Span, ...]"
12
15
  cells: "tuple[Cell, ...]"
13
16
  rows: "tuple[tuple[Cell, ...], ...]"
14
17
  columns: "tuple[tuple[Cell, ...], ...]"
15
18
 
19
+ def __bool__(self) -> bool:
20
+ return self != NULL_TABLE
21
+
22
+ def __hash__(self) -> int:
23
+ """
24
+ Uniquely identify tables by hashing their bounding box and spans.
25
+
26
+ This is an order of magnitude speedup for `.groupby(attrgetter("table"))`
27
+ compared to dataclasses's default __hash__ implementation.
28
+ """
29
+ return hash((self.box, self.spans))
30
+
31
+ @property
32
+ def span(self) -> Span:
33
+ """
34
+ Return the first `Span` the table covers or `NULL_SPAN` otherwise.
35
+ """
36
+ return self.spans[0] if self.spans else NULL_SPAN
37
+
16
38
  @staticmethod
17
39
  def from_dict(table: object) -> "Table":
18
40
  """
@@ -50,9 +72,26 @@ class Table:
50
72
  for column in range(column_count)
51
73
  ) # fmt: skip
52
74
 
75
+ for doc_offset in get(table, list, "doc_offsets"):
76
+ doc_offset["page_num"] = page
77
+
53
78
  return Table(
54
79
  box=Box.from_dict(get(table, dict, "position")),
80
+ spans=tuple(map(Span.from_dict, get(table, list, "doc_offsets"))),
55
81
  cells=cells,
56
82
  rows=rows,
57
83
  columns=columns,
58
84
  )
85
+
86
+
87
+ # It's more ergonomic to represent the lack of tables with a special null table object
88
+ # rather than using `None` or raising an error. This lets you e.g. group by the `table`
89
+ # attribute without having to constantly check for `None`, while still allowing you do
90
+ # a "None check" with `bool(extraction.table)` or `extraction.table == NULL_TABLE`.
91
+ NULL_TABLE: Final = Table(
92
+ box=NULL_BOX,
93
+ spans=tuple(),
94
+ cells=tuple(),
95
+ rows=tuple(),
96
+ columns=tuple(),
97
+ )
@@ -1,7 +1,9 @@
1
1
  from dataclasses import dataclass
2
+ from typing import Final
2
3
 
3
- from ..results import Box, Span
4
- from ..results.utils import get
4
+ from .box import NULL_BOX, Box
5
+ from .span import NULL_SPAN, Span
6
+ from .utils import get
5
7
 
6
8
 
7
9
  @dataclass(frozen=True)
@@ -10,6 +12,9 @@ class Token:
10
12
  box: Box
11
13
  span: Span
12
14
 
15
+ def __bool__(self) -> bool:
16
+ return self != NULL_TOKEN
17
+
13
18
  @staticmethod
14
19
  def from_dict(token: object) -> "Token":
15
20
  """
@@ -23,3 +28,14 @@ class Token:
23
28
  box=Box.from_dict(get(token, dict, "position")),
24
29
  span=Span.from_dict(get(token, dict, "doc_offset")),
25
30
  )
31
+
32
+
33
+ # It's more ergonomic to represent the lack of tokens with a special null token object
34
+ # rather than using `None` or raising an error. This lets you e.g. sort by the `token`
35
+ # attribute without having to constantly check for `None`, while still allowing you do
36
+ # a "None check" with `bool(extraction.token)` or `extraction.token == NULL_TOKEN`.
37
+ NULL_TOKEN: Final = Token(
38
+ text="",
39
+ box=NULL_BOX,
40
+ span=NULL_SPAN,
41
+ )
@@ -0,0 +1,69 @@
1
+ import json
2
+ from typing import Any, TypeVar
3
+
4
+ Value = TypeVar("Value")
5
+
6
+
7
+ def get(value: object, value_type: "type[Value]", *keys: "str | int") -> Value:
8
+ """
9
+ Return the value of type `value_type` obtained by traversing `value` using `keys`.
10
+ Raise an error if a key doesn't exist or the value has the wrong type.
11
+ """
12
+ for key in keys:
13
+ if isinstance(value, dict):
14
+ if key in value:
15
+ value = value[key]
16
+ else:
17
+ raise KeyError(f"{key!r} not in {value.keys()!r}")
18
+ elif isinstance(value, list):
19
+ if isinstance(key, int):
20
+ if 0 <= key < len(value):
21
+ value = value[key]
22
+ else:
23
+ raise IndexError(f"{key} out of range [0,{len(value)})")
24
+ else:
25
+ raise TypeError(f"list can't be indexed with {key!r}")
26
+ else:
27
+ raise TypeError(f"{type(value)} can't be traversed")
28
+
29
+ if isinstance(value, value_type):
30
+ return value
31
+ else:
32
+ raise TypeError(f"value `{value!r}` doesn't have type {value_type}")
33
+
34
+
35
+ def has(value: object, value_type: "type[Value]", *keys: "str | int") -> bool:
36
+ """
37
+ Check if `value` can be traversed using `keys` to a value of type `value_type`.
38
+ """
39
+ for key in keys:
40
+ if isinstance(value, dict) and key in value:
41
+ value = value[key]
42
+ elif isinstance(value, list) and isinstance(key, int) and 0 <= key < len(value): # fmt: skip # noqa: E501
43
+ value = value[key]
44
+ else:
45
+ return False
46
+
47
+ return isinstance(value, value_type)
48
+
49
+
50
+ def json_loaded(value: "Any") -> "Any":
51
+ """
52
+ Ensure `value` has been loaded as JSON.
53
+ """
54
+ value = str_decoded(value)
55
+
56
+ if isinstance(value, str):
57
+ value = json.loads(value)
58
+
59
+ return value
60
+
61
+
62
+ def str_decoded(value: str | bytes) -> str:
63
+ """
64
+ Ensure `value` has been decoded to a string.
65
+ """
66
+ if isinstance(value, bytes):
67
+ value = value.decode()
68
+
69
+ return value
@@ -11,7 +11,13 @@ class SubmissionIdsPendingAutoReview(GraphQLRequest): # type: ignore[misc, no-a
11
11
  query SubmissionIdsPendingAutoReview($workflowIds: [Int]) {
12
12
  submissions(
13
13
  desc: false
14
- filters: { status: PENDING_AUTO_REVIEW }
14
+ filters: {
15
+ AND: [
16
+ { status: PENDING_AUTO_REVIEW }
17
+ { filesDeleted: false }
18
+ { retrieved: false }
19
+ ]
20
+ }
15
21
  limit: 1000
16
22
  orderBy: ID
17
23
  workflowIds: $workflowIds
@@ -39,13 +45,16 @@ class SubmissionIdsPendingDownstream(GraphQLRequest): # type: ignore[misc, no-a
39
45
  submissions(
40
46
  desc: false
41
47
  filters: {
42
- AND: {
43
- retrieved: false
44
- OR: [
45
- { status: COMPLETE }
46
- { status: FAILED }
47
- ]
48
- }
48
+ AND: [
49
+ {
50
+ OR: [
51
+ { status: COMPLETE }
52
+ { status: FAILED }
53
+ ]
54
+ }
55
+ { filesDeleted: false }
56
+ { retrieved: false }
57
+ ]
49
58
  }
50
59
  limit: 1000
51
60
  orderBy: ID
@@ -1,13 +1,10 @@
1
1
  from typing import TYPE_CHECKING, TypeAlias, TypeVar, overload
2
2
 
3
+ from ..etloutput import NULL_BOX, NULL_SPAN, Box, Span
3
4
  from .document import Document
4
- from .errors import ResultError
5
5
  from .predictionlist import PredictionList
6
6
  from .predictions import (
7
- NULL_BOX,
8
7
  NULL_CITATION,
9
- NULL_SPAN,
10
- Box,
11
8
  Classification,
12
9
  DocumentExtraction,
13
10
  Extraction,
@@ -15,7 +12,6 @@ from .predictions import (
15
12
  FormExtractionType,
16
13
  Group,
17
14
  Prediction,
18
- Span,
19
15
  Summarization,
20
16
  Unbundling,
21
17
  )
@@ -45,7 +41,6 @@ __all__ = (
45
41
  "Prediction",
46
42
  "PredictionList",
47
43
  "Result",
48
- "ResultError",
49
44
  "Review",
50
45
  "ReviewType",
51
46
  "Span",
@@ -1,12 +1,9 @@
1
1
  import re
2
- from typing import TYPE_CHECKING
2
+ from typing import Any
3
3
 
4
4
  from .task import TaskType
5
5
  from .utils import get, has
6
6
 
7
- if TYPE_CHECKING:
8
- from typing import Any
9
-
10
7
 
11
8
  def normalize_result_dict(result: "Any") -> None:
12
9
  """
@@ -57,6 +54,13 @@ def normalize_prediction_dict(task_type: TaskType, prediction: "Any") -> None:
57
54
  ) and not has(prediction, list, "spans"):
58
55
  prediction["spans"] = []
59
56
 
57
+ # Form Extraction bounding boxes may very rarely have a `.0` decimal place,
58
+ # which causes them to fail strict validation.
59
+ if task_type == TaskType.FORM_EXTRACTION:
60
+ for edge in ("top", "left", "right", "bottom"):
61
+ if has(prediction, float, edge):
62
+ prediction[edge] = int(prediction[edge])
63
+
60
64
  # Form Extractions added in review may lack bounding box information.
61
65
  # These values will match `NULL_BOX`.
62
66
  if task_type == TaskType.FORM_EXTRACTION and not has(prediction, int, "top"):