threadsense 0.1.0a0__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.
- threadsense/__init__.py +5 -0
- threadsense/__main__.py +6 -0
- threadsense/api_server.py +246 -0
- threadsense/batching.py +306 -0
- threadsense/cli.py +718 -0
- threadsense/cli_display.py +91 -0
- threadsense/config.py +580 -0
- threadsense/connectors/__init__.py +1 -0
- threadsense/connectors/reddit.py +426 -0
- threadsense/errors.py +80 -0
- threadsense/inference/__init__.py +20 -0
- threadsense/inference/contracts.py +151 -0
- threadsense/inference/local_runtime.py +282 -0
- threadsense/inference/prompts.py +153 -0
- threadsense/inference/router.py +115 -0
- threadsense/logging_config.py +34 -0
- threadsense/models/__init__.py +59 -0
- threadsense/models/analysis.py +229 -0
- threadsense/models/canonical.py +217 -0
- threadsense/models/report.py +211 -0
- threadsense/observability.py +180 -0
- threadsense/pipeline/__init__.py +33 -0
- threadsense/pipeline/analyze.py +369 -0
- threadsense/pipeline/normalize.py +177 -0
- threadsense/pipeline/storage.py +107 -0
- threadsense/reporting/__init__.py +12 -0
- threadsense/reporting/build.py +123 -0
- threadsense/reporting/quality.py +91 -0
- threadsense/reporting/render.py +58 -0
- threadsense/workflows.py +352 -0
- threadsense-0.1.0a0.dist-info/METADATA +258 -0
- threadsense-0.1.0a0.dist-info/RECORD +35 -0
- threadsense-0.1.0a0.dist-info/WHEEL +4 -0
- threadsense-0.1.0a0.dist-info/entry_points.txt +2 -0
- threadsense-0.1.0a0.dist-info/licenses/LICENSE +21 -0
threadsense/__init__.py
ADDED
threadsense/__main__.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from threadsense.config import AppConfig
|
|
13
|
+
from threadsense.errors import ApiInputError, ThreadSenseError
|
|
14
|
+
from threadsense.inference import InferenceTask
|
|
15
|
+
from threadsense.observability import DEFAULT_METRICS, MetricsRegistry, TraceContext, observe_stage
|
|
16
|
+
from threadsense.workflows import (
|
|
17
|
+
RedditConnectorFactory,
|
|
18
|
+
analyze_normalized_thread,
|
|
19
|
+
fetch_reddit_thread,
|
|
20
|
+
infer_analysis,
|
|
21
|
+
report_analysis,
|
|
22
|
+
)
|
|
23
|
+
from threadsense.workflows import normalize_reddit_thread as normalize_reddit_workflow
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ApiServerHandle:
|
|
28
|
+
server: ThreadingHTTPServer
|
|
29
|
+
thread: threading.Thread
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_url(self) -> str:
|
|
33
|
+
host, port = self.server.server_address[:2]
|
|
34
|
+
resolved_host = host.decode("utf-8") if isinstance(host, bytes) else host
|
|
35
|
+
return f"http://{resolved_host}:{port}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ServerDependencies:
|
|
40
|
+
config: AppConfig
|
|
41
|
+
logger: logging.Logger
|
|
42
|
+
connector_factory: RedditConnectorFactory
|
|
43
|
+
registry: MetricsRegistry
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def start_api_server(
|
|
47
|
+
*,
|
|
48
|
+
config: AppConfig,
|
|
49
|
+
logger: logging.Logger,
|
|
50
|
+
connector_factory: RedditConnectorFactory,
|
|
51
|
+
registry: MetricsRegistry = DEFAULT_METRICS,
|
|
52
|
+
host: str | None = None,
|
|
53
|
+
port: int | None = None,
|
|
54
|
+
) -> ApiServerHandle:
|
|
55
|
+
dependencies = ServerDependencies(
|
|
56
|
+
config=config,
|
|
57
|
+
logger=logger,
|
|
58
|
+
connector_factory=connector_factory,
|
|
59
|
+
registry=registry,
|
|
60
|
+
)
|
|
61
|
+
server = ThreadingHTTPServer(
|
|
62
|
+
(host or config.api.host, port or config.api.port),
|
|
63
|
+
build_handler(dependencies),
|
|
64
|
+
)
|
|
65
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
66
|
+
thread.start()
|
|
67
|
+
return ApiServerHandle(server=server, thread=thread)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_handler(dependencies: ServerDependencies) -> type[BaseHTTPRequestHandler]:
|
|
71
|
+
class ThreadSenseApiHandler(BaseHTTPRequestHandler):
|
|
72
|
+
server_version = "ThreadSenseHTTP/1.0"
|
|
73
|
+
|
|
74
|
+
def do_GET(self) -> None: # noqa: N802
|
|
75
|
+
trace = TraceContext.create(run_id="api", source_name="http")
|
|
76
|
+
if self.path == "/v1/healthz":
|
|
77
|
+
self._write_json(HTTPStatus.OK, {"status": "ready"})
|
|
78
|
+
return
|
|
79
|
+
if self.path == "/v1/metrics":
|
|
80
|
+
with observe_stage(
|
|
81
|
+
registry=dependencies.registry,
|
|
82
|
+
logger=dependencies.logger,
|
|
83
|
+
trace=trace,
|
|
84
|
+
stage="api_metrics",
|
|
85
|
+
):
|
|
86
|
+
body = dependencies.registry.render_prometheus().encode("utf-8")
|
|
87
|
+
self.send_response(HTTPStatus.OK)
|
|
88
|
+
self.send_header("Content-Type", "text/plain; version=0.0.4")
|
|
89
|
+
self.send_header("Content-Length", str(len(body)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(body)
|
|
92
|
+
return
|
|
93
|
+
self._write_error(
|
|
94
|
+
HTTPStatus.NOT_FOUND,
|
|
95
|
+
ApiInputError("API route does not exist", details={"path": self.path}),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def do_POST(self) -> None: # noqa: N802
|
|
99
|
+
trace = TraceContext.create(run_id="api", source_name="http")
|
|
100
|
+
try:
|
|
101
|
+
with observe_stage(
|
|
102
|
+
registry=dependencies.registry,
|
|
103
|
+
logger=dependencies.logger,
|
|
104
|
+
trace=trace,
|
|
105
|
+
stage="api_request",
|
|
106
|
+
labels={"path": self.path},
|
|
107
|
+
):
|
|
108
|
+
payload = self._read_json_body(dependencies.config.api.max_request_bytes)
|
|
109
|
+
response = self._dispatch(payload, trace)
|
|
110
|
+
except ThreadSenseError as error:
|
|
111
|
+
self._write_error(HTTPStatus.BAD_REQUEST, error)
|
|
112
|
+
return
|
|
113
|
+
self._write_json(HTTPStatus.OK, response)
|
|
114
|
+
|
|
115
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
def _dispatch(self, payload: dict[str, Any], trace: TraceContext) -> dict[str, Any]:
|
|
119
|
+
if self.path == "/v1/fetch/reddit":
|
|
120
|
+
return fetch_reddit_thread(
|
|
121
|
+
config=dependencies.config,
|
|
122
|
+
logger=dependencies.logger,
|
|
123
|
+
trace=trace,
|
|
124
|
+
url=required_str(payload, "url"),
|
|
125
|
+
output_path=optional_path(payload, "output_path"),
|
|
126
|
+
expand_more=optional_bool(payload, "expand_more", False),
|
|
127
|
+
flat=optional_bool(payload, "flat", False),
|
|
128
|
+
connector_factory=dependencies.connector_factory,
|
|
129
|
+
registry=dependencies.registry,
|
|
130
|
+
)
|
|
131
|
+
if self.path == "/v1/normalize/reddit":
|
|
132
|
+
return normalize_reddit_workflow(
|
|
133
|
+
config=dependencies.config,
|
|
134
|
+
logger=dependencies.logger,
|
|
135
|
+
trace=trace,
|
|
136
|
+
input_path=required_path(payload, "input_path"),
|
|
137
|
+
output_path=optional_path(payload, "output_path"),
|
|
138
|
+
registry=dependencies.registry,
|
|
139
|
+
)
|
|
140
|
+
if self.path == "/v1/analyze/normalized":
|
|
141
|
+
return analyze_normalized_thread(
|
|
142
|
+
config=dependencies.config,
|
|
143
|
+
logger=dependencies.logger,
|
|
144
|
+
trace=trace,
|
|
145
|
+
input_path=required_path(payload, "input_path"),
|
|
146
|
+
output_path=optional_path(payload, "output_path"),
|
|
147
|
+
registry=dependencies.registry,
|
|
148
|
+
)
|
|
149
|
+
if self.path == "/v1/report/analysis":
|
|
150
|
+
return report_analysis(
|
|
151
|
+
config=dependencies.config,
|
|
152
|
+
logger=dependencies.logger,
|
|
153
|
+
trace=trace,
|
|
154
|
+
input_path=required_path(payload, "input_path"),
|
|
155
|
+
output_path=optional_path(payload, "output_path"),
|
|
156
|
+
report_format=optional_str(payload, "format", "markdown"),
|
|
157
|
+
with_summary=optional_bool(payload, "with_summary", False),
|
|
158
|
+
summary_required=optional_bool(payload, "summary_required", False),
|
|
159
|
+
registry=dependencies.registry,
|
|
160
|
+
)
|
|
161
|
+
if self.path == "/v1/infer/analysis":
|
|
162
|
+
return infer_analysis(
|
|
163
|
+
config=dependencies.config,
|
|
164
|
+
logger=dependencies.logger,
|
|
165
|
+
trace=trace,
|
|
166
|
+
input_path=required_path(payload, "input_path"),
|
|
167
|
+
task=InferenceTask(optional_str(payload, "task", "analysis_summary")),
|
|
168
|
+
required=optional_bool(payload, "required", False),
|
|
169
|
+
registry=dependencies.registry,
|
|
170
|
+
)
|
|
171
|
+
raise ApiInputError("API route does not exist", details={"path": self.path})
|
|
172
|
+
|
|
173
|
+
def _read_json_body(self, max_request_bytes: int) -> dict[str, Any]:
|
|
174
|
+
content_length_header = self.headers.get("Content-Length", "0")
|
|
175
|
+
try:
|
|
176
|
+
content_length = int(content_length_header)
|
|
177
|
+
except ValueError as error:
|
|
178
|
+
raise ApiInputError(
|
|
179
|
+
"Content-Length header is invalid",
|
|
180
|
+
details={"content_length": content_length_header},
|
|
181
|
+
) from error
|
|
182
|
+
if content_length <= 0:
|
|
183
|
+
raise ApiInputError("request body is required")
|
|
184
|
+
if content_length > max_request_bytes:
|
|
185
|
+
raise ApiInputError(
|
|
186
|
+
"request body exceeds max_request_bytes",
|
|
187
|
+
details={
|
|
188
|
+
"content_length": content_length,
|
|
189
|
+
"max_request_bytes": max_request_bytes,
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
raw_body = self.rfile.read(content_length)
|
|
193
|
+
try:
|
|
194
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
195
|
+
except json.JSONDecodeError as error:
|
|
196
|
+
raise ApiInputError("request body must be valid JSON") from error
|
|
197
|
+
if not isinstance(payload, dict):
|
|
198
|
+
raise ApiInputError("request body must decode to an object")
|
|
199
|
+
return payload
|
|
200
|
+
|
|
201
|
+
def _write_json(self, status_code: HTTPStatus, payload: dict[str, Any]) -> None:
|
|
202
|
+
body = json.dumps(payload, indent=2).encode("utf-8")
|
|
203
|
+
self.send_response(status_code.value)
|
|
204
|
+
self.send_header("Content-Type", "application/json")
|
|
205
|
+
self.send_header("Content-Length", str(len(body)))
|
|
206
|
+
self.end_headers()
|
|
207
|
+
self.wfile.write(body)
|
|
208
|
+
|
|
209
|
+
def _write_error(self, status_code: HTTPStatus, error: ThreadSenseError) -> None:
|
|
210
|
+
self._write_json(status_code, {"status": "error", "error": error.to_dict()})
|
|
211
|
+
|
|
212
|
+
return ThreadSenseApiHandler
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def required_str(payload: dict[str, Any], key: str) -> str:
|
|
216
|
+
value = payload.get(key)
|
|
217
|
+
if not isinstance(value, str) or not value:
|
|
218
|
+
raise ApiInputError("request field is invalid", details={"key": key})
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def optional_str(payload: dict[str, Any], key: str, default: str) -> str:
|
|
223
|
+
value = payload.get(key, default)
|
|
224
|
+
if not isinstance(value, str) or not value:
|
|
225
|
+
raise ApiInputError("request field is invalid", details={"key": key})
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def optional_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
|
|
230
|
+
value = payload.get(key, default)
|
|
231
|
+
if not isinstance(value, bool):
|
|
232
|
+
raise ApiInputError("request field is invalid", details={"key": key})
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def required_path(payload: dict[str, Any], key: str) -> Path:
|
|
237
|
+
return Path(required_str(payload, key))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def optional_path(payload: dict[str, Any], key: str) -> Path | None:
|
|
241
|
+
value = payload.get(key)
|
|
242
|
+
if value is None:
|
|
243
|
+
return None
|
|
244
|
+
if not isinstance(value, str) or not value:
|
|
245
|
+
raise ApiInputError("request field is invalid", details={"key": key})
|
|
246
|
+
return Path(value)
|
threadsense/batching.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from concurrent.futures import ALL_COMPLETED, FIRST_EXCEPTION, Future, ThreadPoolExecutor, wait
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from threadsense.config import AppConfig
|
|
12
|
+
from threadsense.errors import BatchBoundaryError, ResourceLimitError, SchemaBoundaryError
|
|
13
|
+
from threadsense.observability import DEFAULT_METRICS, MetricsRegistry, TraceContext
|
|
14
|
+
from threadsense.pipeline.storage import read_json, write_json
|
|
15
|
+
from threadsense.workflows import RedditConnectorFactory, run_reddit_pipeline
|
|
16
|
+
|
|
17
|
+
BATCH_MANIFEST_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class BatchJob:
|
|
22
|
+
job_id: str
|
|
23
|
+
source_name: str
|
|
24
|
+
thread_url: str
|
|
25
|
+
expand_more: bool
|
|
26
|
+
flat: bool
|
|
27
|
+
report_format: str
|
|
28
|
+
with_summary: bool
|
|
29
|
+
summary_required: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BatchManifest:
|
|
34
|
+
run_name: str
|
|
35
|
+
jobs: list[BatchJob]
|
|
36
|
+
created_at_utc: float
|
|
37
|
+
manifest_version: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BatchJobResult:
|
|
42
|
+
job_id: str
|
|
43
|
+
source_name: str
|
|
44
|
+
thread_url: str
|
|
45
|
+
status: str
|
|
46
|
+
error: dict[str, Any] | None
|
|
47
|
+
outputs: dict[str, Any] | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class BatchRunResult:
|
|
52
|
+
run_name: str
|
|
53
|
+
manifest_path: str
|
|
54
|
+
started_at_utc: float
|
|
55
|
+
completed_at_utc: float
|
|
56
|
+
total_jobs: int
|
|
57
|
+
succeeded_jobs: int
|
|
58
|
+
failed_jobs: int
|
|
59
|
+
worker_count: int
|
|
60
|
+
jobs: list[BatchJobResult]
|
|
61
|
+
reproducibility: dict[str, Any]
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"artifact_kind": "batch_run",
|
|
66
|
+
"schema_version": BATCH_MANIFEST_VERSION,
|
|
67
|
+
"batch_run": asdict(self),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_batch_manifest(path: Path) -> BatchManifest:
|
|
72
|
+
payload = read_json(path)
|
|
73
|
+
manifest_version = payload.get("manifest_version")
|
|
74
|
+
if manifest_version != BATCH_MANIFEST_VERSION:
|
|
75
|
+
raise SchemaBoundaryError(
|
|
76
|
+
"batch manifest version is unsupported",
|
|
77
|
+
details={"manifest_version": manifest_version},
|
|
78
|
+
)
|
|
79
|
+
jobs_data = payload.get("jobs")
|
|
80
|
+
if not isinstance(jobs_data, list) or not jobs_data:
|
|
81
|
+
raise SchemaBoundaryError("batch manifest jobs field is invalid")
|
|
82
|
+
jobs = [job_from_dict(item) for item in jobs_data]
|
|
83
|
+
ensure_unique_job_ids(jobs)
|
|
84
|
+
return BatchManifest(
|
|
85
|
+
run_name=required_str(payload, "run_name"),
|
|
86
|
+
jobs=jobs,
|
|
87
|
+
created_at_utc=required_float(payload, "created_at_utc"),
|
|
88
|
+
manifest_version=manifest_version,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_batch_manifest(
|
|
93
|
+
*,
|
|
94
|
+
config: AppConfig,
|
|
95
|
+
logger: logging.Logger,
|
|
96
|
+
manifest_path: Path,
|
|
97
|
+
output_path: Path | None,
|
|
98
|
+
connector_factory: RedditConnectorFactory,
|
|
99
|
+
registry: MetricsRegistry = DEFAULT_METRICS,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
manifest = load_batch_manifest(manifest_path)
|
|
102
|
+
validate_manifest_limits(manifest, config.batch.max_jobs)
|
|
103
|
+
worker_count = resolve_worker_count(len(manifest.jobs), config.batch.max_workers)
|
|
104
|
+
started_at_utc = time()
|
|
105
|
+
|
|
106
|
+
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
107
|
+
future_map = {
|
|
108
|
+
executor.submit(
|
|
109
|
+
run_batch_job,
|
|
110
|
+
config=config,
|
|
111
|
+
logger=logger,
|
|
112
|
+
job=job,
|
|
113
|
+
run_name=manifest.run_name,
|
|
114
|
+
connector_factory=connector_factory,
|
|
115
|
+
registry=registry,
|
|
116
|
+
): job
|
|
117
|
+
for job in manifest.jobs
|
|
118
|
+
}
|
|
119
|
+
done, not_done = wait(
|
|
120
|
+
future_map.keys(),
|
|
121
|
+
return_when=FIRST_EXCEPTION if config.batch.fail_fast else ALL_COMPLETED,
|
|
122
|
+
)
|
|
123
|
+
if config.batch.fail_fast:
|
|
124
|
+
for future in done:
|
|
125
|
+
if future.exception() is not None:
|
|
126
|
+
for pending in not_done:
|
|
127
|
+
pending.cancel()
|
|
128
|
+
break
|
|
129
|
+
job_results = [resolve_job_result(future_map[future], future) for future in future_map]
|
|
130
|
+
|
|
131
|
+
completed_at_utc = time()
|
|
132
|
+
result = BatchRunResult(
|
|
133
|
+
run_name=manifest.run_name,
|
|
134
|
+
manifest_path=str(manifest_path),
|
|
135
|
+
started_at_utc=started_at_utc,
|
|
136
|
+
completed_at_utc=completed_at_utc,
|
|
137
|
+
total_jobs=len(job_results),
|
|
138
|
+
succeeded_jobs=sum(1 for job in job_results if job.status == "ready"),
|
|
139
|
+
failed_jobs=sum(1 for job in job_results if job.status == "error"),
|
|
140
|
+
worker_count=worker_count,
|
|
141
|
+
jobs=sorted(job_results, key=lambda job: job.job_id),
|
|
142
|
+
reproducibility={
|
|
143
|
+
"privacy_mode": config.privacy_mode.value,
|
|
144
|
+
"inference_backend": config.inference_backend.value,
|
|
145
|
+
"runtime_enabled": config.runtime.enabled,
|
|
146
|
+
"runtime_model": config.runtime.model,
|
|
147
|
+
"storage_root": str(config.storage.root_dir),
|
|
148
|
+
"max_workers": config.batch.max_workers,
|
|
149
|
+
"runtime_concurrency": config.limits.runtime_concurrency,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
resolved_output_path = output_path or (
|
|
153
|
+
config.storage.root_dir / config.storage.batch_dirname / f"{manifest.run_name}.json"
|
|
154
|
+
)
|
|
155
|
+
write_json(resolved_output_path, result.to_dict())
|
|
156
|
+
return {
|
|
157
|
+
"status": "ready" if result.failed_jobs == 0 else "degraded",
|
|
158
|
+
"artifact_type": "batch_run",
|
|
159
|
+
"manifest_path": str(manifest_path),
|
|
160
|
+
"output_path": str(resolved_output_path),
|
|
161
|
+
"run_name": manifest.run_name,
|
|
162
|
+
"worker_count": worker_count,
|
|
163
|
+
"total_jobs": result.total_jobs,
|
|
164
|
+
"succeeded_jobs": result.succeeded_jobs,
|
|
165
|
+
"failed_jobs": result.failed_jobs,
|
|
166
|
+
"jobs": [asdict(job_result) for job_result in result.jobs],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_batch_job(
|
|
171
|
+
*,
|
|
172
|
+
config: AppConfig,
|
|
173
|
+
logger: logging.Logger,
|
|
174
|
+
job: BatchJob,
|
|
175
|
+
run_name: str,
|
|
176
|
+
connector_factory: RedditConnectorFactory,
|
|
177
|
+
registry: MetricsRegistry,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
if job.source_name != "reddit":
|
|
180
|
+
raise BatchBoundaryError(
|
|
181
|
+
"batch job source is unsupported",
|
|
182
|
+
details={"job_id": job.job_id, "source_name": job.source_name},
|
|
183
|
+
)
|
|
184
|
+
trace = TraceContext.create(
|
|
185
|
+
run_id=run_name,
|
|
186
|
+
source_name=job.source_name,
|
|
187
|
+
job_id=job.job_id,
|
|
188
|
+
)
|
|
189
|
+
return run_reddit_pipeline(
|
|
190
|
+
config=config,
|
|
191
|
+
logger=logger,
|
|
192
|
+
trace=trace,
|
|
193
|
+
url=job.thread_url,
|
|
194
|
+
expand_more=job.expand_more,
|
|
195
|
+
flat=job.flat,
|
|
196
|
+
report_format=job.report_format,
|
|
197
|
+
with_summary=job.with_summary,
|
|
198
|
+
summary_required=job.summary_required,
|
|
199
|
+
connector_factory=connector_factory,
|
|
200
|
+
registry=registry,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_worker_count(job_count: int, max_workers: int) -> int:
|
|
205
|
+
if job_count <= 0:
|
|
206
|
+
raise ResourceLimitError("batch run must contain at least one job")
|
|
207
|
+
if max_workers <= 0:
|
|
208
|
+
raise ResourceLimitError("batch max_workers must be greater than zero")
|
|
209
|
+
return min(job_count, max_workers)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def validate_manifest_limits(manifest: BatchManifest, max_jobs: int) -> None:
|
|
213
|
+
if len(manifest.jobs) > max_jobs:
|
|
214
|
+
raise ResourceLimitError(
|
|
215
|
+
"batch manifest exceeds configured max_jobs limit",
|
|
216
|
+
details={"job_count": len(manifest.jobs), "max_jobs": max_jobs},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def resolve_job_result(job: BatchJob, future: Future[dict[str, Any]]) -> BatchJobResult:
|
|
221
|
+
try:
|
|
222
|
+
outputs = future.result()
|
|
223
|
+
except Exception as error:
|
|
224
|
+
return BatchJobResult(
|
|
225
|
+
job_id=job.job_id,
|
|
226
|
+
source_name=job.source_name,
|
|
227
|
+
thread_url=job.thread_url,
|
|
228
|
+
status="error",
|
|
229
|
+
error=job_error_payload(error),
|
|
230
|
+
outputs=None,
|
|
231
|
+
)
|
|
232
|
+
return BatchJobResult(
|
|
233
|
+
job_id=job.job_id,
|
|
234
|
+
source_name=job.source_name,
|
|
235
|
+
thread_url=job.thread_url,
|
|
236
|
+
status="ready",
|
|
237
|
+
error=None,
|
|
238
|
+
outputs=outputs,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def job_error_payload(error: Exception) -> dict[str, Any]:
|
|
243
|
+
if isinstance(error, (BatchBoundaryError, ResourceLimitError, SchemaBoundaryError)):
|
|
244
|
+
return error.to_dict()
|
|
245
|
+
to_dict = getattr(error, "to_dict", None)
|
|
246
|
+
if callable(to_dict):
|
|
247
|
+
payload = to_dict()
|
|
248
|
+
if isinstance(payload, dict):
|
|
249
|
+
return payload
|
|
250
|
+
return {
|
|
251
|
+
"type": error.__class__.__name__,
|
|
252
|
+
"code": "unhandled_error",
|
|
253
|
+
"message": str(error),
|
|
254
|
+
"details": {},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def job_from_dict(payload: Mapping[str, Any]) -> BatchJob:
|
|
259
|
+
return BatchJob(
|
|
260
|
+
job_id=required_str(payload, "job_id"),
|
|
261
|
+
source_name=required_str(payload, "source_name"),
|
|
262
|
+
thread_url=required_str(payload, "thread_url"),
|
|
263
|
+
expand_more=required_bool(payload, "expand_more"),
|
|
264
|
+
flat=required_bool(payload, "flat"),
|
|
265
|
+
report_format=required_str(payload, "report_format"),
|
|
266
|
+
with_summary=required_bool(payload, "with_summary"),
|
|
267
|
+
summary_required=required_bool(payload, "summary_required"),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def ensure_unique_job_ids(jobs: list[BatchJob]) -> None:
|
|
272
|
+
job_ids = [job.job_id for job in jobs]
|
|
273
|
+
if len(set(job_ids)) != len(job_ids):
|
|
274
|
+
raise SchemaBoundaryError("batch manifest contains duplicate job_id values")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def required_str(payload: Mapping[str, Any], key: str) -> str:
|
|
278
|
+
value = payload.get(key)
|
|
279
|
+
if not isinstance(value, str) or not value:
|
|
280
|
+
raise SchemaBoundaryError(
|
|
281
|
+
"batch manifest string field is invalid",
|
|
282
|
+
details={"key": key},
|
|
283
|
+
)
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def required_float(payload: Mapping[str, Any], key: str) -> float:
|
|
288
|
+
value = payload.get(key)
|
|
289
|
+
if isinstance(value, int):
|
|
290
|
+
return float(value)
|
|
291
|
+
if not isinstance(value, float):
|
|
292
|
+
raise SchemaBoundaryError(
|
|
293
|
+
"batch manifest float field is invalid",
|
|
294
|
+
details={"key": key},
|
|
295
|
+
)
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def required_bool(payload: Mapping[str, Any], key: str) -> bool:
|
|
300
|
+
value = payload.get(key)
|
|
301
|
+
if not isinstance(value, bool):
|
|
302
|
+
raise SchemaBoundaryError(
|
|
303
|
+
"batch manifest boolean field is invalid",
|
|
304
|
+
details={"key": key},
|
|
305
|
+
)
|
|
306
|
+
return value
|