indico_toolkit 6.14.0__py3-none-any.whl

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 (83) hide show
  1. indico_toolkit/__init__.py +24 -0
  2. indico_toolkit/association/__init__.py +15 -0
  3. indico_toolkit/association/association.py +65 -0
  4. indico_toolkit/association/extracted_tokens.py +106 -0
  5. indico_toolkit/association/line_items.py +189 -0
  6. indico_toolkit/association/positioning.py +301 -0
  7. indico_toolkit/association/splitting.py +41 -0
  8. indico_toolkit/auto_populate/__init__.py +3 -0
  9. indico_toolkit/auto_populate/populator.py +362 -0
  10. indico_toolkit/auto_populate/types.py +66 -0
  11. indico_toolkit/auto_review/__init__.py +6 -0
  12. indico_toolkit/auto_review/auto_review_functions.py +123 -0
  13. indico_toolkit/auto_review/auto_reviewer.py +62 -0
  14. indico_toolkit/client.py +35 -0
  15. indico_toolkit/errors.py +32 -0
  16. indico_toolkit/etloutput/__init__.py +210 -0
  17. indico_toolkit/etloutput/cell.py +47 -0
  18. indico_toolkit/etloutput/errors.py +16 -0
  19. indico_toolkit/etloutput/etloutput.py +110 -0
  20. indico_toolkit/etloutput/range.py +30 -0
  21. indico_toolkit/etloutput/table.py +44 -0
  22. indico_toolkit/etloutput/token.py +25 -0
  23. indico_toolkit/indico_wrapper/__init__.py +15 -0
  24. indico_toolkit/indico_wrapper/dataset.py +114 -0
  25. indico_toolkit/indico_wrapper/doc_extraction.py +79 -0
  26. indico_toolkit/indico_wrapper/download.py +193 -0
  27. indico_toolkit/indico_wrapper/indico_wrapper.py +119 -0
  28. indico_toolkit/indico_wrapper/modelop.py +200 -0
  29. indico_toolkit/indico_wrapper/reviewer.py +111 -0
  30. indico_toolkit/indico_wrapper/workflow.py +231 -0
  31. indico_toolkit/metrics/__init__.py +11 -0
  32. indico_toolkit/metrics/compare_ground_truth.py +123 -0
  33. indico_toolkit/metrics/compare_models.py +180 -0
  34. indico_toolkit/metrics/metrics.py +373 -0
  35. indico_toolkit/metrics/plotting.py +102 -0
  36. indico_toolkit/ocr/__init__.py +9 -0
  37. indico_toolkit/ocr/customocr_object.py +42 -0
  38. indico_toolkit/ocr/ondoc_object.py +93 -0
  39. indico_toolkit/ocr/standard_object.py +54 -0
  40. indico_toolkit/pipelines/__init__.py +3 -0
  41. indico_toolkit/pipelines/file_processing.py +157 -0
  42. indico_toolkit/polling/__init__.py +8 -0
  43. indico_toolkit/polling/autoreview.py +204 -0
  44. indico_toolkit/polling/downstream.py +145 -0
  45. indico_toolkit/polling/queries.py +68 -0
  46. indico_toolkit/py.typed +0 -0
  47. indico_toolkit/results/__init__.py +104 -0
  48. indico_toolkit/results/document.py +78 -0
  49. indico_toolkit/results/errors.py +4 -0
  50. indico_toolkit/results/model.py +60 -0
  51. indico_toolkit/results/normalization.py +159 -0
  52. indico_toolkit/results/predictionlist.py +395 -0
  53. indico_toolkit/results/predictions/__init__.py +88 -0
  54. indico_toolkit/results/predictions/box.py +58 -0
  55. indico_toolkit/results/predictions/citation.py +47 -0
  56. indico_toolkit/results/predictions/classification.py +50 -0
  57. indico_toolkit/results/predictions/documentextraction.py +160 -0
  58. indico_toolkit/results/predictions/extraction.py +35 -0
  59. indico_toolkit/results/predictions/formextraction.py +121 -0
  60. indico_toolkit/results/predictions/group.py +29 -0
  61. indico_toolkit/results/predictions/prediction.py +41 -0
  62. indico_toolkit/results/predictions/span.py +43 -0
  63. indico_toolkit/results/predictions/summarization.py +103 -0
  64. indico_toolkit/results/predictions/unbundling.py +51 -0
  65. indico_toolkit/results/result.py +152 -0
  66. indico_toolkit/results/review.py +32 -0
  67. indico_toolkit/results/utilities.py +73 -0
  68. indico_toolkit/retry.py +94 -0
  69. indico_toolkit/snapshots/__init__.py +3 -0
  70. indico_toolkit/snapshots/snapshot.py +340 -0
  71. indico_toolkit/structure/__init__.py +3 -0
  72. indico_toolkit/structure/create_structure.py +232 -0
  73. indico_toolkit/structure/queries.py +119 -0
  74. indico_toolkit/structure/utils.py +16 -0
  75. indico_toolkit/types/__init__.py +12 -0
  76. indico_toolkit/types/classification.py +92 -0
  77. indico_toolkit/types/extractions.py +227 -0
  78. indico_toolkit/types/predictions.py +28 -0
  79. indico_toolkit/types/workflow_object.py +133 -0
  80. indico_toolkit-6.14.0.dist-info/LICENSE +21 -0
  81. indico_toolkit-6.14.0.dist-info/METADATA +144 -0
  82. indico_toolkit-6.14.0.dist-info/RECORD +83 -0
  83. indico_toolkit-6.14.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,157 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ from os.path import isfile
5
+ from pathlib import Path
6
+ from typing import Iterable, List, Tuple, Union
7
+
8
+
9
+ class FileProcessing:
10
+ """
11
+ Class to support common file processing operations
12
+ """
13
+
14
+ def __init__(self, file_paths: List[str] = None):
15
+ if file_paths is None:
16
+ file_paths = []
17
+ self.file_paths: List[str] = file_paths
18
+ self.invalid_suffix_paths = set()
19
+
20
+ def get_file_paths_from_dir(
21
+ self,
22
+ path_to_dir: str,
23
+ accepted_types: Tuple[str] = ("pdf", "tiff", "tif", "doc", "docx"),
24
+ recursive_search: bool = False,
25
+ ):
26
+ """
27
+ Recursively find all file in specified types within a target directory
28
+
29
+ Args:
30
+ path_to_dir (str): root directory containing files
31
+ accepted_types (Tuple[str], optional): Valid extensions types to process.
32
+ Defaults to ("pdf", "tiff", "tif", "doc", "docx").
33
+ recursive_search (bool): search sub directories as well. Defaults to False.
34
+
35
+ Raises:
36
+ Exception: Must have at least 1 valid file
37
+ """
38
+ if recursive_search:
39
+ self._recursive_file_search(path_to_dir, accepted_types)
40
+ else:
41
+ self._non_recursive_file_search(path_to_dir, accepted_types)
42
+
43
+ if len(self.file_paths) == 0:
44
+ raise RuntimeError(
45
+ f"There are no files ending with {accepted_types} in {path_to_dir}"
46
+ )
47
+
48
+ print(
49
+ f"Found {len(self.file_paths)} valid files and "
50
+ f"{len(self.invalid_suffix_paths)} paths with invalid suffixes."
51
+ )
52
+
53
+ def move_all_file_paths(
54
+ self,
55
+ origin_dir: str,
56
+ destination_dir: str,
57
+ accepted_types: Tuple[str],
58
+ copy_files: bool = False,
59
+ ):
60
+ self.get_file_paths_from_dir(origin_dir, accepted_types, True)
61
+ destination_dir = Path(destination_dir)
62
+ if destination_dir.is_dir:
63
+ for file in self.file_paths:
64
+ initial_filepath = Path(file)
65
+ file_to_be_moved = initial_filepath.name
66
+ new_path_name = destination_dir / file_to_be_moved
67
+ if not copy_files:
68
+ initial_filepath.rename(new_path_name)
69
+ else:
70
+ shutil.copyfile(initial_filepath, new_path_name)
71
+ else:
72
+ raise RuntimeError(f"{destination_dir} is not a valid directory")
73
+
74
+ def batch_files(self, batch_size: int = 20) -> List[str]:
75
+ for i in range(0, len(self.file_paths), batch_size):
76
+ yield self.file_paths[i : i + batch_size]
77
+
78
+ def remove_files_if_processed(self, processed_files: Iterable[str]):
79
+ """
80
+ Removes files from self.file_paths if they are part of provided file iterable
81
+ Args:
82
+ processed_files (Iterable[str]): iterable of file names, NOT full paths,
83
+ e.g. ["invoice.pdf",]
84
+ """
85
+ unprocessed_filepaths = []
86
+ for filepath in self.file_paths:
87
+ if self.file_name_from_path(filepath) not in processed_files:
88
+ unprocessed_filepaths.append(filepath)
89
+ print(
90
+ f"Removing {len(self.file_paths) - len(unprocessed_filepaths)} "
91
+ "files from file_paths"
92
+ )
93
+ self.file_paths = unprocessed_filepaths
94
+
95
+ @staticmethod
96
+ def read_json(path_to_json: str) -> Union[dict, list]:
97
+ with open(path_to_json, "r") as f:
98
+ return json.load(f)
99
+
100
+ @staticmethod
101
+ def file_exists(path_to_file: str) -> bool:
102
+ return os.path.isfile(path_to_file)
103
+
104
+ @property
105
+ def parent_directory_of_filepaths(self) -> List[str]:
106
+ return [Path(i).parent.name for i in self.file_paths]
107
+
108
+ @staticmethod
109
+ def join_paths(start_path: str, end_path: str) -> str:
110
+ return os.path.join(start_path, end_path)
111
+
112
+ @staticmethod
113
+ def get_file_name_without_suffix(filepath: str) -> str:
114
+ return Path(filepath).stem
115
+
116
+ @staticmethod
117
+ def get_file_path_suffix(filepath: str) -> str:
118
+ return Path(filepath).suffix
119
+
120
+ @staticmethod
121
+ def file_name_from_path(filepath: str) -> str:
122
+ return Path(filepath).name
123
+
124
+ @staticmethod
125
+ def get_parent_path(filepath: str) -> str:
126
+ return str(Path(filepath).parent)
127
+
128
+ def __iter__(self):
129
+ """
130
+ Object is iterable on file_paths
131
+ """
132
+ current = 0
133
+ while current < len(self.file_paths):
134
+ yield self.file_paths[current]
135
+ current += 1
136
+
137
+ def _recursive_file_search(self, path_to_dir: str, accepted_types: Tuple[str]):
138
+ for root, _, files in os.walk(path_to_dir):
139
+ for name in files:
140
+ if self._check_acceptable_suffix(name, accepted_types):
141
+ self.file_paths.append(os.path.join(root, name))
142
+ else:
143
+ self.invalid_suffix_paths.add(os.path.join(root, name))
144
+
145
+ def _non_recursive_file_search(self, path_to_dir: str, accepted_types: Tuple[str]):
146
+ files = [os.path.join(path_to_dir, f) for f in os.listdir(path_to_dir)]
147
+ for fpath in files:
148
+ if self._check_acceptable_suffix(fpath, accepted_types):
149
+ self.file_paths.append(fpath)
150
+ elif isfile(fpath):
151
+ self.invalid_suffix_paths.add(fpath)
152
+
153
+ @staticmethod
154
+ def _check_acceptable_suffix(string: str, accepted_suffixes: Tuple[str]) -> bool:
155
+ if string.lower().endswith(accepted_suffixes):
156
+ return True
157
+ return False
@@ -0,0 +1,8 @@
1
+ from .autoreview import AutoReviewed, AutoReviewPoller
2
+ from .downstream import DownstreamPoller
3
+
4
+ __all__ = (
5
+ "AutoReviewed",
6
+ "AutoReviewPoller",
7
+ "DownstreamPoller",
8
+ )
@@ -0,0 +1,204 @@
1
+ import asyncio
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from indico import AsyncIndicoClient, IndicoConfig # type: ignore[import-untyped]
7
+ from indico.queries import ( # type: ignore[import-untyped]
8
+ GetSubmission,
9
+ JobStatus,
10
+ RetrieveStorageObject,
11
+ SubmitReview,
12
+ )
13
+
14
+ from .. import etloutput, results
15
+ from ..etloutput import EtlOutput
16
+ from ..results import Document, Result
17
+ from ..retry import retry
18
+ from .queries import SubmissionIdsPendingAutoReview
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Awaitable, Callable
22
+ from typing import Any, NoReturn, TypeAlias
23
+
24
+ SubmissionId: TypeAlias = int
25
+ Worker: TypeAlias = asyncio.Task[None]
26
+ WorkerQueue: TypeAlias = asyncio.Queue[tuple[SubmissionId, Worker]]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class AutoReviewed:
33
+ changes: "dict[str, Any] | list[dict[str, Any]]"
34
+ reject: bool = False
35
+ stp: bool = False
36
+
37
+
38
+ class AutoReviewPoller:
39
+ """
40
+ Polls for submissions requiring auto review, processes them,
41
+ and submits the review results concurrently.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ config: IndicoConfig,
47
+ workflow_id: int,
48
+ auto_review: "Callable[[Result, dict[Document, EtlOutput]], Awaitable[AutoReviewed]]", # noqa: E501
49
+ *,
50
+ worker_count: int = 8,
51
+ spawn_rate: float = 1,
52
+ poll_delay: float = 30,
53
+ load_etl_output: bool = True,
54
+ load_text: bool = True,
55
+ load_tokens: bool = True,
56
+ load_tables: bool = False,
57
+ retry_count: int = 4,
58
+ retry_wait: float = 1,
59
+ retry_backoff: float = 4,
60
+ retry_jitter: float = 0.5,
61
+ ):
62
+ self._config = config
63
+ self._workflow_id = workflow_id
64
+ self._auto_review = auto_review
65
+ self._worker_count = worker_count
66
+ self._spawn_rate = spawn_rate
67
+ self._poll_delay = poll_delay
68
+ self._load_etl_output = load_etl_output
69
+ self._load_text = load_text
70
+ self._load_tokens = load_tokens
71
+ self._load_tables = load_tables
72
+
73
+ self._retry = retry(
74
+ Exception,
75
+ count=retry_count,
76
+ wait=retry_wait,
77
+ backoff=retry_backoff,
78
+ jitter=retry_jitter,
79
+ )
80
+ self._worker_slots = asyncio.Semaphore(worker_count)
81
+ self._worker_queue: "WorkerQueue" = asyncio.Queue(1)
82
+ self._processing_submission_ids: "set[SubmissionId]" = set()
83
+
84
+ async def poll_forever(self) -> "NoReturn": # type: ignore[misc]
85
+ logger.info(
86
+ "Starting auto review poller for: "
87
+ f"host={self._config.host} "
88
+ f"workflow_id={self._workflow_id} "
89
+ f"worker_count={self._worker_count}"
90
+ )
91
+
92
+ async with AsyncIndicoClient(self._config) as client:
93
+ self._client_call = self._retry(client.call)
94
+ await asyncio.gather(
95
+ self._spawn_workers(),
96
+ *(self._reap_workers() for _ in range(self._worker_count)),
97
+ )
98
+
99
+ async def _retrieve_storage_object(self, url: str) -> object:
100
+ return await self._client_call(RetrieveStorageObject(url))
101
+
102
+ async def _spawn_workers(self) -> None:
103
+ """
104
+ Poll for submissions pending auto review and spawn workers to process them.
105
+ `self._worker_slots` limits the number of workers that can run concurrently.
106
+ Submission IDs in progress are tracked with `self._processing_submission_ids`.
107
+ """
108
+ logger.info(
109
+ f"Polling submissions pending auto review every {self._poll_delay} seconds"
110
+ )
111
+
112
+ while True:
113
+ try:
114
+ submission_ids: "set[SubmissionId]" = await self._client_call(
115
+ SubmissionIdsPendingAutoReview(self._workflow_id)
116
+ )
117
+ except Exception:
118
+ logger.exception("Error occurred while polling submissions")
119
+ await asyncio.sleep(self._poll_delay)
120
+ continue
121
+
122
+ submission_ids -= self._processing_submission_ids
123
+
124
+ if not submission_ids:
125
+ await asyncio.sleep(self._poll_delay)
126
+ continue
127
+
128
+ for submission_id in submission_ids:
129
+ await self._worker_slots.acquire()
130
+ logger.info(f"Spawning worker for {submission_id=}")
131
+ self._processing_submission_ids.add(submission_id)
132
+ worker = asyncio.create_task(self._worker(submission_id))
133
+ await self._worker_queue.put((submission_id, worker))
134
+ await asyncio.sleep(1 / self._spawn_rate)
135
+
136
+ async def _worker(self, submission_id: "SubmissionId") -> None:
137
+ """
138
+ Process a single submission by retrieving submission metadata, the result file,
139
+ etl output, calling `self._auto_review`, and submitting changes.
140
+ """
141
+ logger.info(f"Retrieving metadata for {submission_id=}")
142
+ submission = await self._client_call(GetSubmission(submission_id))
143
+
144
+ logger.info(f"Retrieving results for {submission_id=}")
145
+ result = await results.load_async(
146
+ submission.result_file,
147
+ reader=self._retrieve_storage_object,
148
+ )
149
+
150
+ if self._load_etl_output:
151
+ logger.info(f"Retrieving etl output for {submission_id=}")
152
+ etl_outputs = {
153
+ document: await etloutput.load_async(
154
+ document.etl_output_uri,
155
+ reader=self._retrieve_storage_object,
156
+ text=self._load_text,
157
+ tokens=self._load_tokens,
158
+ tables=self._load_tables,
159
+ )
160
+ for document in result.documents
161
+ if not document.failed
162
+ }
163
+ else:
164
+ logger.info(f"Skipping etl output for {submission_id=}")
165
+ etl_outputs = {}
166
+
167
+ logger.info(f"Applying auto review for {submission_id=}")
168
+ auto_reviewed = await self._auto_review(result, etl_outputs)
169
+
170
+ logger.info(f"Submitting auto review for {submission_id=}")
171
+ job = await self._client_call(
172
+ SubmitReview(
173
+ submission_id,
174
+ changes=auto_reviewed.changes,
175
+ rejected=auto_reviewed.reject,
176
+ force_complete=auto_reviewed.stp,
177
+ )
178
+ )
179
+ job = await self._client_call(JobStatus(job.id))
180
+
181
+ if job.status == "SUCCESS":
182
+ logger.info(f"Completed auto review of {submission_id=}")
183
+ else:
184
+ logger.error(
185
+ f"Submit failed for {submission_id=}: "
186
+ f"{job.status=!r} {job.result=!r}"
187
+ )
188
+
189
+ async def _reap_workers(self) -> None:
190
+ """
191
+ Reap completed workers, releasing their slots for new tasks. Log errors for
192
+ submissions that failed to process. Remove their submission IDs from
193
+ `self._processing_submission_ids` to be retried.
194
+ """
195
+ while True:
196
+ submission_id, worker = await self._worker_queue.get()
197
+
198
+ try:
199
+ await worker
200
+ except Exception:
201
+ logger.exception(f"Error occurred while processing {submission_id=}")
202
+
203
+ self._processing_submission_ids.remove(submission_id)
204
+ self._worker_slots.release()
@@ -0,0 +1,145 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import TYPE_CHECKING
4
+
5
+ from indico import AsyncIndicoClient, IndicoConfig # type: ignore[import-untyped]
6
+ from indico.queries import ( # type: ignore[import-untyped]
7
+ GetSubmission,
8
+ UpdateSubmission,
9
+ )
10
+ from indico.types import Submission # type: ignore[import-untyped]
11
+
12
+ from ..retry import retry
13
+ from .queries import SubmissionIdsPendingDownstream
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Awaitable, Callable
17
+ from typing import NoReturn, TypeAlias
18
+
19
+ SubmissionId: TypeAlias = int
20
+ Worker: TypeAlias = asyncio.Task[None]
21
+ WorkerQueue: TypeAlias = asyncio.Queue[tuple[SubmissionId, Worker]]
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class DownstreamPoller:
27
+ """
28
+ Polls for completed and failed submissions pending downstream egestion, processes
29
+ them concurrently, and marks them as retrieved.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ config: IndicoConfig,
35
+ workflow_id: int,
36
+ downstream: "Callable[[Submission], Awaitable[None]]",
37
+ *,
38
+ worker_count: int = 8,
39
+ spawn_rate: float = 1,
40
+ poll_delay: float = 30,
41
+ retry_count: int = 4,
42
+ retry_wait: float = 1,
43
+ retry_backoff: float = 4,
44
+ retry_jitter: float = 0.5,
45
+ ):
46
+ self._config = config
47
+ self._workflow_id = workflow_id
48
+ self._downstream = downstream
49
+ self._worker_count = worker_count
50
+ self._spawn_rate = spawn_rate
51
+ self._poll_delay = poll_delay
52
+
53
+ self._retry = retry(
54
+ Exception,
55
+ count=retry_count,
56
+ wait=retry_wait,
57
+ backoff=retry_backoff,
58
+ jitter=retry_jitter,
59
+ )
60
+ self._worker_slots = asyncio.Semaphore(worker_count)
61
+ self._worker_queue: "WorkerQueue" = asyncio.Queue(1)
62
+ self._processing_submission_ids: "set[SubmissionId]" = set()
63
+
64
+ async def poll_forever(self) -> "NoReturn": # type: ignore[misc]
65
+ logger.info(
66
+ "Starting downstream poller for: "
67
+ f"host={self._config.host} "
68
+ f"workflow_id={self._workflow_id} "
69
+ f"worker_count={self._worker_count}"
70
+ )
71
+
72
+ async with AsyncIndicoClient(self._config) as client:
73
+ self._client_call = self._retry(client.call)
74
+ await asyncio.gather(
75
+ self._spawn_workers(),
76
+ *(self._reap_workers() for _ in range(self._worker_count)),
77
+ )
78
+
79
+ async def _spawn_workers(self) -> None:
80
+ """
81
+ Poll for completed and failed submissions and spawn workers to send them
82
+ downstream. `self._worker_slots` limits the number of workers that can run
83
+ concurrently. Submission IDs in progress are tracked with
84
+ `self._processing_submission_ids`.
85
+ """
86
+ logger.info(
87
+ f"Polling submissions pending downstream every {self._poll_delay} seconds"
88
+ )
89
+
90
+ while True:
91
+ try:
92
+ submission_ids: "set[SubmissionId]" = await self._client_call(
93
+ SubmissionIdsPendingDownstream(self._workflow_id)
94
+ )
95
+ except Exception:
96
+ logger.exception("Error occurred while polling submissions")
97
+ await asyncio.sleep(self._poll_delay)
98
+ continue
99
+
100
+ submission_ids -= self._processing_submission_ids
101
+
102
+ if not submission_ids:
103
+ await asyncio.sleep(self._poll_delay)
104
+ continue
105
+
106
+ for submission_id in submission_ids:
107
+ await self._worker_slots.acquire()
108
+ logger.info(f"Spawning worker for {submission_id=}")
109
+ self._processing_submission_ids.add(submission_id)
110
+ worker = asyncio.create_task(self._worker(submission_id))
111
+ await self._worker_queue.put((submission_id, worker))
112
+ await asyncio.sleep(1 / self._spawn_rate)
113
+
114
+ async def _worker(self, submission_id: "SubmissionId") -> None:
115
+ """
116
+ Process a single submission by retrieving submission metadata and calling
117
+ `self._downstream`. Once completed, mark the submission retrieved.
118
+ """
119
+ logger.info(f"Retrieving metadata for {submission_id=}")
120
+ submission = await self._client_call(GetSubmission(submission_id))
121
+
122
+ logger.info(f"Sending {submission_id=} downstream")
123
+ await self._downstream(submission)
124
+
125
+ logger.info(f"Marking {submission_id=} retrieved")
126
+ await self._client_call(UpdateSubmission(submission_id, retrieved=True))
127
+
128
+ logger.info(f"Completed dowstream of {submission_id=}")
129
+
130
+ async def _reap_workers(self) -> None:
131
+ """
132
+ Reap completed workers, releasing their slots for new tasks. Log errors for
133
+ submissions that failed to process. Remove their submission IDs from
134
+ `self._processing_submission_ids` to be retried.
135
+ """
136
+ while True:
137
+ submission_id, worker = await self._worker_queue.get()
138
+
139
+ try:
140
+ await worker
141
+ except Exception:
142
+ logger.exception(f"Error occurred while processing {submission_id=}")
143
+
144
+ self._processing_submission_ids.remove(submission_id)
145
+ self._worker_slots.release()
@@ -0,0 +1,68 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from indico.queries import GraphQLRequest # type: ignore[import-untyped]
4
+
5
+ if TYPE_CHECKING:
6
+ from typing import Any
7
+
8
+
9
+ class SubmissionIdsPendingAutoReview(GraphQLRequest): # type: ignore[misc]
10
+ QUERY = """
11
+ query SubmissionIdsPendingAutoReview($workflowIds: [Int]) {
12
+ submissions(
13
+ desc: false
14
+ filters: { status: PENDING_AUTO_REVIEW }
15
+ limit: 1000
16
+ orderBy: ID
17
+ workflowIds: $workflowIds
18
+ ) {
19
+ submissions {
20
+ id
21
+ }
22
+ }
23
+ }
24
+ """
25
+
26
+ def __init__(self, workflow_id: int):
27
+ super().__init__(self.QUERY, {"workflowIds": [workflow_id]})
28
+
29
+ def process_response(self, response: "Any") -> set[int]:
30
+ response = super().process_response(response)
31
+ return {
32
+ submission["id"] for submission in response["submissions"]["submissions"]
33
+ }
34
+
35
+
36
+ class SubmissionIdsPendingDownstream(GraphQLRequest): # type: ignore[misc]
37
+ QUERY = """
38
+ query SubmissionIdsPendingDownstream($workflowIds: [Int]) {
39
+ submissions(
40
+ desc: false
41
+ filters: {
42
+ AND: {
43
+ retrieved: false
44
+ OR: [
45
+ { status: COMPLETE }
46
+ { status: FAILED }
47
+ ]
48
+ }
49
+ }
50
+ limit: 1000
51
+ orderBy: ID
52
+ workflowIds: $workflowIds
53
+ ) {
54
+ submissions {
55
+ id
56
+ }
57
+ }
58
+ }
59
+ """
60
+
61
+ def __init__(self, workflow_id: int):
62
+ super().__init__(self.QUERY, {"workflowIds": [workflow_id]})
63
+
64
+ def process_response(self, response: "Any") -> set[int]:
65
+ response = super().process_response(response)
66
+ return {
67
+ submission["id"] for submission in response["submissions"]["submissions"]
68
+ }
File without changes
@@ -0,0 +1,104 @@
1
+ import json
2
+ from typing import TYPE_CHECKING
3
+
4
+ from .document import Document
5
+ from .errors import ResultError
6
+ from .model import ModelGroup, ModelGroupType
7
+ from .predictionlist import PredictionList
8
+ from .predictions import (
9
+ NULL_BOX,
10
+ NULL_CITATION,
11
+ NULL_SPAN,
12
+ Box,
13
+ Classification,
14
+ DocumentExtraction,
15
+ Extraction,
16
+ FormExtraction,
17
+ FormExtractionType,
18
+ Group,
19
+ Prediction,
20
+ Span,
21
+ Summarization,
22
+ Unbundling,
23
+ )
24
+ from .result import Result
25
+ from .review import Review, ReviewType
26
+ from .utilities import get
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Awaitable, Callable
30
+
31
+
32
+ __all__ = (
33
+ "Box",
34
+ "Classification",
35
+ "Document",
36
+ "DocumentExtraction",
37
+ "Extraction",
38
+ "FormExtraction",
39
+ "FormExtractionType",
40
+ "Group",
41
+ "load",
42
+ "load_async",
43
+ "ModelGroup",
44
+ "ModelGroupType",
45
+ "NULL_BOX",
46
+ "NULL_CITATION",
47
+ "NULL_SPAN",
48
+ "Prediction",
49
+ "PredictionList",
50
+ "Result",
51
+ "ResultError",
52
+ "Review",
53
+ "ReviewType",
54
+ "Span",
55
+ "Summarization",
56
+ "Unbundling",
57
+ )
58
+
59
+
60
+ def load(result: object, *, reader: "Callable[..., object] | None" = None) -> Result:
61
+ """
62
+ Load `result` as a Result dataclass.
63
+
64
+ `result` can be a dict, a JSON string, or something that can be read with `reader`
65
+ to produce either.
66
+
67
+ ```
68
+ for result_file in result_folder.glob("*.json"):
69
+ result = results.load(result_file, reader=Path.read_text)
70
+ ```
71
+ """
72
+ if reader:
73
+ result = reader(result)
74
+
75
+ return _load(result)
76
+
77
+
78
+ async def load_async(
79
+ result: object, *, reader: "Callable[..., Awaitable[object]] | None" = None
80
+ ) -> Result:
81
+ """
82
+ Load `result` as a Result dataclass.
83
+
84
+ `result` can be a dict, a JSON string, or something that can be read with `reader`
85
+ to produce either.
86
+ """
87
+ if reader:
88
+ result = await reader(result)
89
+
90
+ return _load(result)
91
+
92
+
93
+ def _load(result: object) -> Result:
94
+ if isinstance(result, str) and result.strip().startswith("{"):
95
+ result = json.loads(result)
96
+
97
+ file_version = get(result, int, "file_version")
98
+
99
+ if file_version == 1:
100
+ return Result.from_v1_dict(result)
101
+ elif file_version == 3:
102
+ return Result.from_v3_dict(result)
103
+ else:
104
+ raise ResultError(f"unsupported file version `{file_version!r}`")