jev-align 0.1.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.
- jev_align/__init__.py +13 -0
- jev_align/acquisition.py +80 -0
- jev_align/backends.py +101 -0
- jev_align/capture.py +183 -0
- jev_align/captured_inputs.py +127 -0
- jev_align/cli.py +2561 -0
- jev_align/data.py +197 -0
- jev_align/jev.py +173 -0
- jev_align/metrics.py +156 -0
- jev_align/models.py +503 -0
- jev_align/optimizer.py +482 -0
- jev_align/persistence.py +147 -0
- jev_align/runtime.py +132 -0
- jev_align/sample_data/agent-traces.csv +501 -0
- jev_align/sample_data/hn-stories.csv +8404 -0
- jev_align/sample_data/support-tickets.csv +501 -0
- jev_align/session.py +589 -0
- jev_align-0.1.0.dist-info/METADATA +169 -0
- jev_align-0.1.0.dist-info/RECORD +22 -0
- jev_align-0.1.0.dist-info/WHEEL +4 -0
- jev_align-0.1.0.dist-info/entry_points.txt +3 -0
- jev_align-0.1.0.dist-info/licenses/LICENSE +201 -0
jev_align/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""CLI for building AI Functions with pluggable evaluation backends."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .capture import Capture
|
|
6
|
+
from .runtime import AIFunction
|
|
7
|
+
|
|
8
|
+
__all__ = ["AIFunction", "Capture", "__version__"]
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("jev-align")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0+unknown"
|
jev_align/acquisition.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from statistics import mean, median
|
|
6
|
+
|
|
7
|
+
from .models import Prediction
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ambiguity(probability: float) -> float:
|
|
11
|
+
return 1.0 - 2.0 * abs(probability - 0.5)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prediction_ambiguity(prediction: Prediction) -> float:
|
|
15
|
+
if prediction.label_probabilities is not None:
|
|
16
|
+
return max(
|
|
17
|
+
ambiguity(value) for value in prediction.label_probabilities.values()
|
|
18
|
+
)
|
|
19
|
+
if prediction.confidence is not None:
|
|
20
|
+
return 1.0 - prediction.confidence
|
|
21
|
+
if prediction.probability is None:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"prediction has neither native confidence nor class probability"
|
|
24
|
+
)
|
|
25
|
+
return ambiguity(prediction.probability)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Acquisition:
|
|
30
|
+
prediction: Prediction
|
|
31
|
+
source: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def select_batch(
|
|
35
|
+
predictions: list[Prediction],
|
|
36
|
+
*,
|
|
37
|
+
batch_size: int = 5,
|
|
38
|
+
exploration_count: int = 1,
|
|
39
|
+
seed: int = 0,
|
|
40
|
+
) -> list[Acquisition]:
|
|
41
|
+
if batch_size < 1:
|
|
42
|
+
raise ValueError("batch_size must be positive")
|
|
43
|
+
if not 0 <= exploration_count <= batch_size:
|
|
44
|
+
raise ValueError("exploration_count must be between zero and batch_size")
|
|
45
|
+
if len(predictions) < batch_size:
|
|
46
|
+
raise ValueError("not enough unlabeled rows for an acquisition batch")
|
|
47
|
+
|
|
48
|
+
ambiguous_count = batch_size - exploration_count
|
|
49
|
+
ranked = sorted(
|
|
50
|
+
predictions,
|
|
51
|
+
key=lambda item: (-prediction_ambiguity(item), item.story_id),
|
|
52
|
+
)
|
|
53
|
+
ambiguous = ranked[:ambiguous_count]
|
|
54
|
+
chosen = {item.story_id for item in ambiguous}
|
|
55
|
+
remainder = [item for item in predictions if item.story_id not in chosen]
|
|
56
|
+
rng = random.Random(seed)
|
|
57
|
+
exploration = rng.sample(remainder, exploration_count)
|
|
58
|
+
return [
|
|
59
|
+
*(Acquisition(item, "ambiguous") for item in ambiguous),
|
|
60
|
+
*(Acquisition(item, "exploration") for item in exploration),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ambiguity_summary(predictions: list[Prediction]) -> dict[str, float | int]:
|
|
65
|
+
values = [prediction_ambiguity(item) for item in predictions]
|
|
66
|
+
if not values:
|
|
67
|
+
return {
|
|
68
|
+
"count": 0,
|
|
69
|
+
"mean": 0.0,
|
|
70
|
+
"median": 0.0,
|
|
71
|
+
"at_least_0_5": 0,
|
|
72
|
+
"at_least_0_8": 0,
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
"count": len(values),
|
|
76
|
+
"mean": mean(values),
|
|
77
|
+
"median": median(values),
|
|
78
|
+
"at_least_0_5": sum(value >= 0.5 for value in values),
|
|
79
|
+
"at_least_0_8": sum(value >= 0.8 for value in values),
|
|
80
|
+
}
|
jev_align/backends.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
BackendConfig,
|
|
9
|
+
MulticlassCandidateSpec,
|
|
10
|
+
MultilabelCandidateSpec,
|
|
11
|
+
Prediction,
|
|
12
|
+
ScoreCandidateSpec,
|
|
13
|
+
Story,
|
|
14
|
+
TaskSpec,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
TaskKind = Literal["binary", "multiclass", "multilabel", "score"]
|
|
18
|
+
UncertaintyKind = Literal["calibrated_probability", "native_confidence"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class BackendCapabilities:
|
|
23
|
+
"""What an evaluation backend can reliably provide to active learning."""
|
|
24
|
+
|
|
25
|
+
task_types: frozenset[TaskKind]
|
|
26
|
+
uncertainty: Mapping[TaskKind, UncertaintyKind]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EvaluationBackend(Protocol):
|
|
30
|
+
backend_id: str
|
|
31
|
+
display_name: str
|
|
32
|
+
model: str
|
|
33
|
+
capabilities: BackendCapabilities
|
|
34
|
+
|
|
35
|
+
def evaluate_many(
|
|
36
|
+
self, candidate: TaskSpec, stories: Sequence[Story]
|
|
37
|
+
) -> list[Prediction]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_backend(
|
|
41
|
+
config: BackendConfig, *, concurrency: int
|
|
42
|
+
) -> EvaluationBackend:
|
|
43
|
+
"""Construct the configured adapter without leaking providers into the core."""
|
|
44
|
+
if config.provider == "typesafe":
|
|
45
|
+
from .jev import TypeSafeJevEvaluator
|
|
46
|
+
|
|
47
|
+
return TypeSafeJevEvaluator(model=config.model, concurrency=concurrency)
|
|
48
|
+
raise ValueError(f"unsupported evaluation backend: {config.provider}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def backend_credential_error(
|
|
52
|
+
config: BackendConfig, environment: Mapping[str, str]
|
|
53
|
+
) -> str | None:
|
|
54
|
+
if config.provider == "typesafe" and not environment.get("TYPESAFE_API_KEY"):
|
|
55
|
+
return "TYPESAFE_API_KEY must be set for the TypeSafe backend"
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def backend_display_name(backend: EvaluationBackend) -> str:
|
|
60
|
+
"""Give minimal third-party adapters a useful name without extra boilerplate."""
|
|
61
|
+
return str(getattr(backend, "display_name", backend.__class__.__name__))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def task_kind(candidate: TaskSpec) -> TaskKind:
|
|
65
|
+
if isinstance(candidate, MultilabelCandidateSpec):
|
|
66
|
+
return "multilabel"
|
|
67
|
+
if isinstance(candidate, ScoreCandidateSpec):
|
|
68
|
+
return "score"
|
|
69
|
+
if isinstance(candidate, MulticlassCandidateSpec):
|
|
70
|
+
return "multiclass"
|
|
71
|
+
return "binary"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_backend_for_task(
|
|
75
|
+
backend: EvaluationBackend, candidate: TaskSpec
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Reject adapters that cannot supply uncertainty for the requested task."""
|
|
78
|
+
kind = task_kind(candidate)
|
|
79
|
+
capabilities = backend.capabilities
|
|
80
|
+
if kind not in capabilities.task_types:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"{backend_display_name(backend)} does not support {kind} AI Functions"
|
|
83
|
+
)
|
|
84
|
+
if kind not in capabilities.uncertainty:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{backend_display_name(backend)} does not provide usable uncertainty "
|
|
87
|
+
f"for {kind} AI Functions"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def evaluate_with_progress(
|
|
92
|
+
backend: EvaluationBackend,
|
|
93
|
+
candidate: TaskSpec,
|
|
94
|
+
stories: Sequence[Story],
|
|
95
|
+
description: str,
|
|
96
|
+
) -> list[Prediction]:
|
|
97
|
+
"""Use progress when supported, while preserving lightweight test adapters."""
|
|
98
|
+
method = getattr(backend, "evaluate_many_with_progress", None)
|
|
99
|
+
if callable(method):
|
|
100
|
+
return method(candidate, stories, description)
|
|
101
|
+
return backend.evaluate_many(candidate, stories)
|
jev_align/capture.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Best-effort, bounded local capture. Never makes evaluation requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from queue import Empty, Full, Queue
|
|
13
|
+
from time import monotonic
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from .acquisition import prediction_ambiguity
|
|
17
|
+
from .models import Prediction, Story
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Capture:
|
|
23
|
+
"""One bounded queue and background JSONL writer per instance.
|
|
24
|
+
|
|
25
|
+
Use as a context manager or call close() at application shutdown. Create
|
|
26
|
+
instances inside each worker process, after forking. Captures are disposable
|
|
27
|
+
observations, never human labels or changes to an alignment run.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
directory: str | Path = ".jev-align/captures",
|
|
33
|
+
*,
|
|
34
|
+
ambiguity_threshold: float = 0.8,
|
|
35
|
+
audit_rate: float = 0.05,
|
|
36
|
+
queue_size: int = 256,
|
|
37
|
+
max_record_bytes: int = 64 * 1024,
|
|
38
|
+
max_file_bytes: int = 64 * 1024 * 1024,
|
|
39
|
+
) -> None:
|
|
40
|
+
if not 0 <= ambiguity_threshold <= 1 or not 0 <= audit_rate <= 1:
|
|
41
|
+
raise ValueError("ambiguity_threshold and audit_rate must be in [0, 1]")
|
|
42
|
+
if min(queue_size, max_record_bytes, max_file_bytes) < 1:
|
|
43
|
+
raise ValueError("capture size limits must be positive")
|
|
44
|
+
self.path = Path(directory).resolve() / f"{uuid4().hex}.jsonl"
|
|
45
|
+
self.ambiguity_threshold = ambiguity_threshold
|
|
46
|
+
self.audit_rate = audit_rate
|
|
47
|
+
self.max_record_bytes = max_record_bytes
|
|
48
|
+
self.max_file_bytes = max_file_bytes
|
|
49
|
+
self._queue: Queue[bytes] = Queue(maxsize=queue_size)
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._stop = threading.Event()
|
|
52
|
+
self._pid = os.getpid()
|
|
53
|
+
self._reserved_bytes = 0
|
|
54
|
+
self._written = 0
|
|
55
|
+
self._dropped = 0
|
|
56
|
+
self._error: str | None = None
|
|
57
|
+
self._thread = threading.Thread(
|
|
58
|
+
target=self._write_loop, name="jev-align-capture", daemon=True
|
|
59
|
+
)
|
|
60
|
+
self._thread.start()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def stats(self) -> dict[str, int | str | None]:
|
|
64
|
+
"""Counters for selected records; filtered-out calls are not drops."""
|
|
65
|
+
if os.getpid() != self._pid:
|
|
66
|
+
return {"written": 0, "dropped": 0, "error": "create Capture after fork"}
|
|
67
|
+
with self._lock:
|
|
68
|
+
return {
|
|
69
|
+
"written": self._written,
|
|
70
|
+
"dropped": self._dropped,
|
|
71
|
+
"error": self._error,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def record(
|
|
75
|
+
self,
|
|
76
|
+
story: Story,
|
|
77
|
+
prediction: Prediction,
|
|
78
|
+
*,
|
|
79
|
+
run_id: str,
|
|
80
|
+
definition: dict[str, object],
|
|
81
|
+
fingerprint: str,
|
|
82
|
+
) -> None:
|
|
83
|
+
# An inherited writer has no running thread. Avoid its inherited locks.
|
|
84
|
+
if os.getpid() != self._pid:
|
|
85
|
+
return
|
|
86
|
+
uncertainty = prediction_ambiguity(prediction)
|
|
87
|
+
reason = "ambiguous"
|
|
88
|
+
if uncertainty < self.ambiguity_threshold:
|
|
89
|
+
if random.random() >= self.audit_rate:
|
|
90
|
+
return
|
|
91
|
+
reason = "audit"
|
|
92
|
+
with self._lock:
|
|
93
|
+
if (
|
|
94
|
+
self._stop.is_set()
|
|
95
|
+
or self._queue.full()
|
|
96
|
+
or self._reserved_bytes >= self.max_file_bytes
|
|
97
|
+
or sum(len(value) for value in story.fields.values())
|
|
98
|
+
> self.max_record_bytes
|
|
99
|
+
):
|
|
100
|
+
self._dropped += 1
|
|
101
|
+
return
|
|
102
|
+
payload = {
|
|
103
|
+
"version": 1,
|
|
104
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
105
|
+
"run_id": run_id,
|
|
106
|
+
"definition": definition,
|
|
107
|
+
"fingerprint": fingerprint,
|
|
108
|
+
"input": story.fields,
|
|
109
|
+
"prediction": prediction.model_dump(mode="json"),
|
|
110
|
+
"ambiguity": uncertainty,
|
|
111
|
+
"reason": reason,
|
|
112
|
+
}
|
|
113
|
+
line = (json.dumps(payload) + "\n").encode("utf-8")
|
|
114
|
+
with self._lock:
|
|
115
|
+
if (
|
|
116
|
+
self._stop.is_set()
|
|
117
|
+
or len(line) > self.max_record_bytes
|
|
118
|
+
or self._reserved_bytes + len(line) > self.max_file_bytes
|
|
119
|
+
):
|
|
120
|
+
self._dropped += 1
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
self._queue.put_nowait(line)
|
|
124
|
+
except Full:
|
|
125
|
+
self._dropped += 1
|
|
126
|
+
else:
|
|
127
|
+
self._reserved_bytes += len(line)
|
|
128
|
+
|
|
129
|
+
def _write_loop(self) -> None:
|
|
130
|
+
batch: list[bytes] = []
|
|
131
|
+
try:
|
|
132
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
with self.path.open("xb") as handle:
|
|
134
|
+
deadline = monotonic() + 0.25
|
|
135
|
+
while not self._stop.is_set() or not self._queue.empty():
|
|
136
|
+
try:
|
|
137
|
+
batch.append(
|
|
138
|
+
self._queue.get(timeout=max(0, deadline - monotonic()))
|
|
139
|
+
)
|
|
140
|
+
except Empty:
|
|
141
|
+
pass
|
|
142
|
+
if len(batch) >= 64 or monotonic() >= deadline:
|
|
143
|
+
handle.write(b"".join(batch))
|
|
144
|
+
handle.flush()
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._written += len(batch)
|
|
147
|
+
batch.clear()
|
|
148
|
+
deadline = monotonic() + 0.25
|
|
149
|
+
handle.write(b"".join(batch))
|
|
150
|
+
handle.flush()
|
|
151
|
+
with self._lock:
|
|
152
|
+
self._written += len(batch)
|
|
153
|
+
batch.clear()
|
|
154
|
+
except OSError as error:
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._error = str(error)
|
|
157
|
+
self._stop.set()
|
|
158
|
+
self._dropped += len(batch)
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
self._queue.get_nowait()
|
|
162
|
+
self._dropped += 1
|
|
163
|
+
except Empty:
|
|
164
|
+
break
|
|
165
|
+
logger.warning("JEV capture disabled after a local write error: %s", error)
|
|
166
|
+
|
|
167
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
168
|
+
"""Stop accepting records and wait up to timeout seconds for the writer.
|
|
169
|
+
|
|
170
|
+
Buffered records may be lost on abrupt exit or if shutdown times out.
|
|
171
|
+
No fsync or durability guarantee is added to the request path.
|
|
172
|
+
"""
|
|
173
|
+
if os.getpid() != self._pid:
|
|
174
|
+
return
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._stop.set()
|
|
177
|
+
self._thread.join(timeout=timeout)
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> Capture:
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *_args: object) -> None:
|
|
183
|
+
self.close()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Read deduplicated captured inputs without model calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import heapq
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .models import RunState, Story
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def captured_story_id(fields: dict[str, str]) -> str:
|
|
19
|
+
payload = json.dumps(fields, sort_keys=True).encode("utf-8")
|
|
20
|
+
return "capture-" + hashlib.sha256(payload).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sample_captured_inputs(
|
|
24
|
+
directories: Iterable[Path],
|
|
25
|
+
state: RunState,
|
|
26
|
+
*,
|
|
27
|
+
excluded: Iterable[Story],
|
|
28
|
+
saved: Iterable[Story] = (),
|
|
29
|
+
required: Iterable[Story] = (),
|
|
30
|
+
limit: int | None = None,
|
|
31
|
+
) -> list[Story]:
|
|
32
|
+
"""Keep required inputs plus all other unique inputs by default.
|
|
33
|
+
|
|
34
|
+
An optional limit is retained for callers that explicitly request sampling;
|
|
35
|
+
required inputs do not count toward it. Stable hash priorities avoid
|
|
36
|
+
favoring frequent duplicates or the first file scanned when a limit is used.
|
|
37
|
+
Read only complete lines present when each file is opened, so active writers
|
|
38
|
+
cannot extend the scan.
|
|
39
|
+
"""
|
|
40
|
+
if limit is not None and limit < 1:
|
|
41
|
+
raise ValueError("capture sample limit must be positive")
|
|
42
|
+
excluded_ids = {captured_story_id(story.fields) for story in excluded}
|
|
43
|
+
required_by_id = {
|
|
44
|
+
story.id: story for story in required if story.id not in excluded_ids
|
|
45
|
+
}
|
|
46
|
+
selected: dict[str, Story] = {}
|
|
47
|
+
heap: list[tuple[int, str]] = []
|
|
48
|
+
|
|
49
|
+
def consider(story: Story) -> None:
|
|
50
|
+
if (
|
|
51
|
+
story.id in excluded_ids
|
|
52
|
+
or story.id in required_by_id
|
|
53
|
+
or story.id in selected
|
|
54
|
+
):
|
|
55
|
+
return
|
|
56
|
+
if limit is None:
|
|
57
|
+
selected[story.id] = story
|
|
58
|
+
return
|
|
59
|
+
priority = int(
|
|
60
|
+
hashlib.sha256(
|
|
61
|
+
f"{state.seed}:{state.round_number}:{story.id}".encode()
|
|
62
|
+
).hexdigest(),
|
|
63
|
+
16,
|
|
64
|
+
)
|
|
65
|
+
if len(heap) == limit:
|
|
66
|
+
if priority >= -heap[0][0]:
|
|
67
|
+
return
|
|
68
|
+
_, removed = heapq.heappop(heap)
|
|
69
|
+
del selected[removed]
|
|
70
|
+
heapq.heappush(heap, (-priority, story.id))
|
|
71
|
+
selected[story.id] = story
|
|
72
|
+
|
|
73
|
+
for story in saved:
|
|
74
|
+
consider(story)
|
|
75
|
+
paths = {path for directory in directories for path in directory.glob("*.jsonl")}
|
|
76
|
+
for path in sorted(paths):
|
|
77
|
+
try:
|
|
78
|
+
with path.open("rb") as handle:
|
|
79
|
+
boundary = os.fstat(handle.fileno()).st_size
|
|
80
|
+
oversized = False
|
|
81
|
+
while handle.tell() < boundary:
|
|
82
|
+
line = handle.readline(min(1024 * 1024, boundary - handle.tell()))
|
|
83
|
+
if not line:
|
|
84
|
+
break
|
|
85
|
+
if not line.endswith(b"\n"):
|
|
86
|
+
oversized = True
|
|
87
|
+
continue
|
|
88
|
+
if oversized:
|
|
89
|
+
oversized = False
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
row = json.loads(line)
|
|
93
|
+
except (ValueError, UnicodeError):
|
|
94
|
+
continue
|
|
95
|
+
if not isinstance(row, dict) or row.get("version") != 1:
|
|
96
|
+
continue
|
|
97
|
+
if row.get("run_id") != state.run_id:
|
|
98
|
+
continue
|
|
99
|
+
fields = row.get("input")
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(fields, dict)
|
|
102
|
+
or not fields
|
|
103
|
+
or not all(
|
|
104
|
+
isinstance(key, str) and isinstance(value, str)
|
|
105
|
+
for key, value in fields.items()
|
|
106
|
+
)
|
|
107
|
+
):
|
|
108
|
+
continue
|
|
109
|
+
expected = (
|
|
110
|
+
["content"]
|
|
111
|
+
if state.column_mode == "all_concatenated"
|
|
112
|
+
else state.selected_columns
|
|
113
|
+
)
|
|
114
|
+
if expected is not None and set(fields) != set(expected):
|
|
115
|
+
continue
|
|
116
|
+
consider(
|
|
117
|
+
Story(id=captured_story_id(fields), row_number=0, fields=fields)
|
|
118
|
+
)
|
|
119
|
+
except OSError as error:
|
|
120
|
+
logger.warning("Could not read capture file %s: %s", path, error)
|
|
121
|
+
selected_ids = (
|
|
122
|
+
sorted(selected) if limit is None else [story_id for _, story_id in sorted(heap)]
|
|
123
|
+
)
|
|
124
|
+
return [
|
|
125
|
+
*(required_by_id[story_id] for story_id in sorted(required_by_id)),
|
|
126
|
+
*(selected[story_id] for story_id in selected_ids),
|
|
127
|
+
]
|