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.
- indico_toolkit/__init__.py +24 -0
- indico_toolkit/association/__init__.py +15 -0
- indico_toolkit/association/association.py +65 -0
- indico_toolkit/association/extracted_tokens.py +106 -0
- indico_toolkit/association/line_items.py +189 -0
- indico_toolkit/association/positioning.py +301 -0
- indico_toolkit/association/splitting.py +41 -0
- indico_toolkit/auto_populate/__init__.py +3 -0
- indico_toolkit/auto_populate/populator.py +362 -0
- indico_toolkit/auto_populate/types.py +66 -0
- indico_toolkit/auto_review/__init__.py +6 -0
- indico_toolkit/auto_review/auto_review_functions.py +123 -0
- indico_toolkit/auto_review/auto_reviewer.py +62 -0
- indico_toolkit/client.py +35 -0
- indico_toolkit/errors.py +32 -0
- indico_toolkit/etloutput/__init__.py +210 -0
- indico_toolkit/etloutput/cell.py +47 -0
- indico_toolkit/etloutput/errors.py +16 -0
- indico_toolkit/etloutput/etloutput.py +110 -0
- indico_toolkit/etloutput/range.py +30 -0
- indico_toolkit/etloutput/table.py +44 -0
- indico_toolkit/etloutput/token.py +25 -0
- indico_toolkit/indico_wrapper/__init__.py +15 -0
- indico_toolkit/indico_wrapper/dataset.py +114 -0
- indico_toolkit/indico_wrapper/doc_extraction.py +79 -0
- indico_toolkit/indico_wrapper/download.py +193 -0
- indico_toolkit/indico_wrapper/indico_wrapper.py +119 -0
- indico_toolkit/indico_wrapper/modelop.py +200 -0
- indico_toolkit/indico_wrapper/reviewer.py +111 -0
- indico_toolkit/indico_wrapper/workflow.py +231 -0
- indico_toolkit/metrics/__init__.py +11 -0
- indico_toolkit/metrics/compare_ground_truth.py +123 -0
- indico_toolkit/metrics/compare_models.py +180 -0
- indico_toolkit/metrics/metrics.py +373 -0
- indico_toolkit/metrics/plotting.py +102 -0
- indico_toolkit/ocr/__init__.py +9 -0
- indico_toolkit/ocr/customocr_object.py +42 -0
- indico_toolkit/ocr/ondoc_object.py +93 -0
- indico_toolkit/ocr/standard_object.py +54 -0
- indico_toolkit/pipelines/__init__.py +3 -0
- indico_toolkit/pipelines/file_processing.py +157 -0
- indico_toolkit/polling/__init__.py +8 -0
- indico_toolkit/polling/autoreview.py +204 -0
- indico_toolkit/polling/downstream.py +145 -0
- indico_toolkit/polling/queries.py +68 -0
- indico_toolkit/py.typed +0 -0
- indico_toolkit/results/__init__.py +104 -0
- indico_toolkit/results/document.py +78 -0
- indico_toolkit/results/errors.py +4 -0
- indico_toolkit/results/model.py +60 -0
- indico_toolkit/results/normalization.py +159 -0
- indico_toolkit/results/predictionlist.py +395 -0
- indico_toolkit/results/predictions/__init__.py +88 -0
- indico_toolkit/results/predictions/box.py +58 -0
- indico_toolkit/results/predictions/citation.py +47 -0
- indico_toolkit/results/predictions/classification.py +50 -0
- indico_toolkit/results/predictions/documentextraction.py +160 -0
- indico_toolkit/results/predictions/extraction.py +35 -0
- indico_toolkit/results/predictions/formextraction.py +121 -0
- indico_toolkit/results/predictions/group.py +29 -0
- indico_toolkit/results/predictions/prediction.py +41 -0
- indico_toolkit/results/predictions/span.py +43 -0
- indico_toolkit/results/predictions/summarization.py +103 -0
- indico_toolkit/results/predictions/unbundling.py +51 -0
- indico_toolkit/results/result.py +152 -0
- indico_toolkit/results/review.py +32 -0
- indico_toolkit/results/utilities.py +73 -0
- indico_toolkit/retry.py +94 -0
- indico_toolkit/snapshots/__init__.py +3 -0
- indico_toolkit/snapshots/snapshot.py +340 -0
- indico_toolkit/structure/__init__.py +3 -0
- indico_toolkit/structure/create_structure.py +232 -0
- indico_toolkit/structure/queries.py +119 -0
- indico_toolkit/structure/utils.py +16 -0
- indico_toolkit/types/__init__.py +12 -0
- indico_toolkit/types/classification.py +92 -0
- indico_toolkit/types/extractions.py +227 -0
- indico_toolkit/types/predictions.py +28 -0
- indico_toolkit/types/workflow_object.py +133 -0
- indico_toolkit-6.14.0.dist-info/LICENSE +21 -0
- indico_toolkit-6.14.0.dist-info/METADATA +144 -0
- indico_toolkit-6.14.0.dist-info/RECORD +83 -0
- 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,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
|
+
}
|
indico_toolkit/py.typed
ADDED
|
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}`")
|