llm-conductor 1.0.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.
- llm_conductor/__init__.py +4 -0
- llm_conductor/__main__.py +7 -0
- llm_conductor/cli/__init__.py +0 -0
- llm_conductor/cli/exit_status.py +84 -0
- llm_conductor/cli/glossary.py +94 -0
- llm_conductor/cli/heartbeat.py +298 -0
- llm_conductor/cli/init.py +156 -0
- llm_conductor/cli/locations.py +80 -0
- llm_conductor/cli/main.py +227 -0
- llm_conductor/cli/runner.py +518 -0
- llm_conductor/cli/targets.py +350 -0
- llm_conductor/cli/validators.py +283 -0
- llm_conductor/config.py +1395 -0
- llm_conductor/constants.py +402 -0
- llm_conductor/core/__init__.py +0 -0
- llm_conductor/core/api_client.py +811 -0
- llm_conductor/core/cache.py +924 -0
- llm_conductor/core/circuit_breaker.py +64 -0
- llm_conductor/core/io.py +484 -0
- llm_conductor/core/locking.py +187 -0
- llm_conductor/core/metrics.py +158 -0
- llm_conductor/core/shutdown.py +159 -0
- llm_conductor/exceptions.py +83 -0
- llm_conductor/interfaces.py +357 -0
- llm_conductor/l10n/__init__.py +0 -0
- llm_conductor/l10n/context.py +771 -0
- llm_conductor/l10n/processor.py +1111 -0
- llm_conductor/l10n/validation.py +188 -0
- llm_conductor/schema.py +563 -0
- llm_conductor/utils.py +317 -0
- llm_conductor-1.0.0.dist-info/METADATA +176 -0
- llm_conductor-1.0.0.dist-info/RECORD +36 -0
- llm_conductor-1.0.0.dist-info/WHEEL +5 -0
- llm_conductor-1.0.0.dist-info/entry_points.txt +2 -0
- llm_conductor-1.0.0.dist-info/licenses/LICENSE +21 -0
- llm_conductor-1.0.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Exit code of a run that may hit several problems at once.
|
|
4
|
+
|
|
5
|
+
Every problem is recorded with its code; the most severe one by
|
|
6
|
+
EXIT_CODE_PRIORITY becomes the process exit code.
|
|
7
|
+
"""
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Iterable, List, NamedTuple
|
|
10
|
+
|
|
11
|
+
from llm_conductor.constants import EXIT_CODE_PRIORITY, EXIT_SUCCESS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _checked(codes: Iterable[int]) -> List[int]:
|
|
15
|
+
"""Reject codes outside the contract - an unknown code has no defined severity."""
|
|
16
|
+
checked = list(codes)
|
|
17
|
+
unknown = [code for code in checked if code not in EXIT_CODE_PRIORITY]
|
|
18
|
+
if unknown:
|
|
19
|
+
raise ValueError(f"exit codes {unknown} are not part of EXIT_CODE_PRIORITY")
|
|
20
|
+
return checked
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def most_severe(codes: Iterable[int]) -> int:
|
|
24
|
+
"""
|
|
25
|
+
The code that wins by EXIT_CODE_PRIORITY.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
codes: Exit codes; each must be listed in EXIT_CODE_PRIORITY.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The most severe code, EXIT_SUCCESS for no codes.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: On a code missing from EXIT_CODE_PRIORITY.
|
|
35
|
+
"""
|
|
36
|
+
ranks = [EXIT_CODE_PRIORITY.index(code) for code in _checked(codes)]
|
|
37
|
+
return EXIT_CODE_PRIORITY[min(ranks)] if ranks else EXIT_SUCCESS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExitRecord(NamedTuple):
|
|
41
|
+
"""One recorded problem of a run."""
|
|
42
|
+
|
|
43
|
+
code: int
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExitStatus:
|
|
48
|
+
"""
|
|
49
|
+
Thread-safe accumulator of the problems of one run.
|
|
50
|
+
|
|
51
|
+
record() may be called from any thread; code and records are consistent
|
|
52
|
+
snapshots taken under the same lock.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._records: List[ExitRecord] = []
|
|
58
|
+
|
|
59
|
+
def record(self, code: int, reason: str) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Record a problem.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
code: Its exit code from EXIT_CODE_PRIORITY.
|
|
65
|
+
reason: Human-readable description for the final log line.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: On a code missing from EXIT_CODE_PRIORITY.
|
|
69
|
+
"""
|
|
70
|
+
_checked([code])
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._records.append(ExitRecord(code, reason))
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def code(self) -> int:
|
|
76
|
+
"""The exit code of the run: the most severe recorded one, EXIT_SUCCESS for none."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
return most_severe(record.code for record in self._records)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def records(self) -> List[ExitRecord]:
|
|
82
|
+
"""A copy of the recorded problems, in recording order."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
return list(self._records)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Where the glossary of a run comes from.
|
|
4
|
+
|
|
5
|
+
The lookup chain has two levels: --glossary PATH (a missing file is an
|
|
6
|
+
error), then glossary.json next to the target (file or manifest), then no
|
|
7
|
+
glossary at all. A glossary is project data, so there is no shared fallback
|
|
8
|
+
next to the executable - it would leak one project's terms into another.
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, Optional
|
|
13
|
+
|
|
14
|
+
from llm_conductor.cli.targets import RunTarget
|
|
15
|
+
from llm_conductor.constants import (
|
|
16
|
+
GLOSSARY_FILENAME,
|
|
17
|
+
GLOSSARY_ORIGIN_OPTION,
|
|
18
|
+
GLOSSARY_ORIGIN_TARGET,
|
|
19
|
+
)
|
|
20
|
+
from llm_conductor.core.io import safe_read_file
|
|
21
|
+
from llm_conductor.exceptions import FileOperationError, TargetError
|
|
22
|
+
from llm_conductor.interfaces import FileOperationsProtocol, Logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class GlossarySource:
|
|
27
|
+
"""The glossary of a run and where it was found."""
|
|
28
|
+
|
|
29
|
+
terms: Dict[str, str] = field(default_factory=dict)
|
|
30
|
+
path: Optional[str] = None
|
|
31
|
+
origin: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
def describe(self) -> str:
|
|
34
|
+
"""One log line naming the source, its level of the chain and the number of terms."""
|
|
35
|
+
if self.path is None:
|
|
36
|
+
return "Glossary: none given and no glossary.json next to the target - working without terms"
|
|
37
|
+
return f"Glossary: {self.path} ({self.origin}), {len(self.terms)} term(s)"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GlossaryResolver:
|
|
41
|
+
"""Walks the lookup chain and loads the glossary it finds."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, file_ops: FileOperationsProtocol, logger: Logger):
|
|
44
|
+
"""
|
|
45
|
+
Args:
|
|
46
|
+
file_ops: File operations for reading the glossary.
|
|
47
|
+
logger: Logger of the run.
|
|
48
|
+
"""
|
|
49
|
+
self._file_ops = file_ops
|
|
50
|
+
self._logger = logger
|
|
51
|
+
|
|
52
|
+
def resolve(self, explicit_path: Optional[str], target: RunTarget) -> GlossarySource:
|
|
53
|
+
"""
|
|
54
|
+
Find and load the glossary of a run.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
explicit_path: Value of --glossary, relative to the working directory.
|
|
58
|
+
target: The run target; its directory holds the implicit glossary.json.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The loaded glossary, or an empty one when the chain finds nothing.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TargetError: If the explicit glossary is missing, or a found
|
|
65
|
+
glossary is unreadable or not an object of strings.
|
|
66
|
+
"""
|
|
67
|
+
if explicit_path:
|
|
68
|
+
path = os.path.abspath(explicit_path)
|
|
69
|
+
if not os.path.isfile(path):
|
|
70
|
+
raise TargetError(f"Glossary {path} not found")
|
|
71
|
+
return self._load(path, GLOSSARY_ORIGIN_OPTION)
|
|
72
|
+
|
|
73
|
+
candidate = os.path.join(target.directory, GLOSSARY_FILENAME)
|
|
74
|
+
if os.path.isfile(candidate):
|
|
75
|
+
return self._load(candidate, GLOSSARY_ORIGIN_TARGET)
|
|
76
|
+
return GlossarySource()
|
|
77
|
+
|
|
78
|
+
def _load(self, path: str, origin: str) -> GlossarySource:
|
|
79
|
+
"""
|
|
80
|
+
Read and check one glossary file.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
TargetError: If it is unreadable or not a {term: translation} object of strings.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
data = safe_read_file(path, f"glossary {path}", self._file_ops, self._logger,
|
|
87
|
+
critical=True, as_text=False)
|
|
88
|
+
except FileOperationError as e:
|
|
89
|
+
raise TargetError(str(e)) from e
|
|
90
|
+
|
|
91
|
+
if not isinstance(data, dict) or not all(
|
|
92
|
+
isinstance(term, str) and isinstance(translation, str) for term, translation in data.items()):
|
|
93
|
+
raise TargetError(f"Glossary {path} must be a JSON object mapping terms to translation strings")
|
|
94
|
+
return GlossarySource(data, path, origin)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Heartbeat JSON snapshot of a run ({cache_dir}/progress_state.json).
|
|
4
|
+
|
|
5
|
+
External monitors read this file; it is a status report, not a lock and not
|
|
6
|
+
the source of truth for the process exit code (that remains sys.exit in main).
|
|
7
|
+
Write failures degrade to a WARNING so the pipeline keeps going.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from llm_conductor.cli.exit_status import ExitRecord
|
|
18
|
+
from llm_conductor.cli.glossary import GlossarySource
|
|
19
|
+
from llm_conductor.cli.targets import RunTarget, TargetEntry
|
|
20
|
+
from llm_conductor.constants import (
|
|
21
|
+
HEARTBEAT_SCHEMA_VERSION,
|
|
22
|
+
HEARTBEAT_STATUS_COMPLETED,
|
|
23
|
+
HEARTBEAT_STATUS_INTERRUPTED,
|
|
24
|
+
HEARTBEAT_STATUS_RUNNING,
|
|
25
|
+
PROGRESS_STATE_FILENAME,
|
|
26
|
+
)
|
|
27
|
+
from llm_conductor.core.io import safe_write_file
|
|
28
|
+
from llm_conductor.exceptions import FileOperationError
|
|
29
|
+
from llm_conductor.interfaces import FileOperationsProtocol, Logger
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HeartbeatWriter:
|
|
33
|
+
"""
|
|
34
|
+
Builds and rewrites the compact progress_state.json of one run.
|
|
35
|
+
|
|
36
|
+
Implements HeartbeatProgressProtocol (begin_stage / note_leaf) for the
|
|
37
|
+
processor; file and final boundaries are driven by TargetRunner.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
cache_dir: str,
|
|
43
|
+
interval: int,
|
|
44
|
+
file_ops: FileOperationsProtocol,
|
|
45
|
+
logger: Logger,
|
|
46
|
+
target: RunTarget,
|
|
47
|
+
profile_name: str,
|
|
48
|
+
stages: Sequence[str],
|
|
49
|
+
glossary: GlossarySource,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Args:
|
|
53
|
+
cache_dir: Resolved [cache].dir (created on first write if needed).
|
|
54
|
+
interval: Leaf rewrite cadence; ``<= 0`` skips leaf-only flushes.
|
|
55
|
+
file_ops: Atomic text writer.
|
|
56
|
+
logger: Run logger (WARNING on a failed rewrite).
|
|
57
|
+
target: Resolved run target.
|
|
58
|
+
profile_name: ``[meta].name`` of the active llm.toml.
|
|
59
|
+
stages: Operation types that will run, in pipeline order.
|
|
60
|
+
glossary: Glossary source of the run (same facts as the INFO log).
|
|
61
|
+
"""
|
|
62
|
+
self._path = os.path.join(cache_dir, PROGRESS_STATE_FILENAME)
|
|
63
|
+
self._cache_dir = cache_dir
|
|
64
|
+
self._interval = interval
|
|
65
|
+
self._file_ops = file_ops
|
|
66
|
+
self._logger = logger
|
|
67
|
+
|
|
68
|
+
self._started = False
|
|
69
|
+
self._final = False
|
|
70
|
+
self._started_at = 0.0
|
|
71
|
+
self._stage_started_at = 0.0
|
|
72
|
+
self._leaves_since_flush = 0
|
|
73
|
+
|
|
74
|
+
self._status = HEARTBEAT_STATUS_RUNNING
|
|
75
|
+
self._exit_code: Optional[int] = None
|
|
76
|
+
self._problems: List[Dict[str, Any]] = []
|
|
77
|
+
|
|
78
|
+
self._target = {
|
|
79
|
+
'path': target.path,
|
|
80
|
+
'name': target.name,
|
|
81
|
+
'kind': 'manifest' if target.is_manifest else 'file',
|
|
82
|
+
}
|
|
83
|
+
self._profile = {'name': profile_name}
|
|
84
|
+
self._stages = list(stages)
|
|
85
|
+
self._glossary = {
|
|
86
|
+
'origin': glossary.origin,
|
|
87
|
+
'path': glossary.path,
|
|
88
|
+
'term_count': len(glossary.terms),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
self._files_total = len(target.entries)
|
|
92
|
+
self._all_relative = [entry.relative_path for entry in target.entries]
|
|
93
|
+
self._done: List[str] = []
|
|
94
|
+
self._failed: List[Dict[str, str]] = []
|
|
95
|
+
self._pending: List[str] = list(self._all_relative)
|
|
96
|
+
|
|
97
|
+
self._file_index = 0
|
|
98
|
+
self._current_file: Optional[str] = None
|
|
99
|
+
self._stage: Optional[str] = None
|
|
100
|
+
self._leaves_done = 0
|
|
101
|
+
self._leaves_total = 0
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def path(self) -> str:
|
|
105
|
+
"""Absolute or relative path of progress_state.json."""
|
|
106
|
+
return self._path
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def started(self) -> bool:
|
|
110
|
+
"""True after the first successful start() call."""
|
|
111
|
+
return self._started
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def finalized(self) -> bool:
|
|
115
|
+
"""True after finalize() has written the terminal snapshot."""
|
|
116
|
+
return self._final
|
|
117
|
+
|
|
118
|
+
def start(self) -> None:
|
|
119
|
+
"""Mark the run as running and write the initial snapshot."""
|
|
120
|
+
if self._final:
|
|
121
|
+
return
|
|
122
|
+
self._started = True
|
|
123
|
+
self._started_at = time.monotonic()
|
|
124
|
+
self._status = HEARTBEAT_STATUS_RUNNING
|
|
125
|
+
self._exit_code = None
|
|
126
|
+
self._flush()
|
|
127
|
+
|
|
128
|
+
def on_file_start(self, entry: TargetEntry, file_index: int) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Record that a file is about to be processed.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
entry: The file being started.
|
|
134
|
+
file_index: 1-based index among target entries.
|
|
135
|
+
"""
|
|
136
|
+
if not self._started or self._final:
|
|
137
|
+
return
|
|
138
|
+
self._file_index = file_index
|
|
139
|
+
self._current_file = entry.relative_path
|
|
140
|
+
if entry.relative_path in self._pending:
|
|
141
|
+
self._pending = [path for path in self._pending if path != entry.relative_path]
|
|
142
|
+
self._stage = None
|
|
143
|
+
self._leaves_done = 0
|
|
144
|
+
self._leaves_total = 0
|
|
145
|
+
self._flush()
|
|
146
|
+
|
|
147
|
+
def on_file_done(self, relative_path: str, status_name: str) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Record the outcome of one file and rewrite the snapshot.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
relative_path: Path under the result stages.
|
|
153
|
+
status_name: FileStatus value (done / failed / crashed / aborted).
|
|
154
|
+
"""
|
|
155
|
+
if not self._started or self._final:
|
|
156
|
+
return
|
|
157
|
+
if status_name == 'done':
|
|
158
|
+
if relative_path not in self._done:
|
|
159
|
+
self._done.append(relative_path)
|
|
160
|
+
else:
|
|
161
|
+
self._failed.append({'path': relative_path, 'status': status_name})
|
|
162
|
+
self._current_file = None
|
|
163
|
+
self._stage = None
|
|
164
|
+
self._leaves_done = 0
|
|
165
|
+
self._leaves_total = 0
|
|
166
|
+
self._flush()
|
|
167
|
+
|
|
168
|
+
def begin_stage(self, stage: str, leaves_total: int) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Start a translation or verification stage for the current file.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
stage: OPERATION_TRANSLATION or OPERATION_VERIFICATION.
|
|
174
|
+
leaves_total: Pre-scanned string-leaf count for this stage.
|
|
175
|
+
"""
|
|
176
|
+
if not self._started or self._final:
|
|
177
|
+
return
|
|
178
|
+
self._stage = stage
|
|
179
|
+
self._leaves_total = max(0, leaves_total)
|
|
180
|
+
self._leaves_done = 0
|
|
181
|
+
self._leaves_since_flush = 0
|
|
182
|
+
self._stage_started_at = time.monotonic()
|
|
183
|
+
self._flush()
|
|
184
|
+
|
|
185
|
+
def note_leaf(self) -> None:
|
|
186
|
+
"""Count one finished string leaf; flush every ``interval`` leaves."""
|
|
187
|
+
if not self._started or self._final:
|
|
188
|
+
return
|
|
189
|
+
self._leaves_done += 1
|
|
190
|
+
if self._interval <= 0:
|
|
191
|
+
return
|
|
192
|
+
self._leaves_since_flush += 1
|
|
193
|
+
if self._leaves_since_flush >= self._interval:
|
|
194
|
+
self._leaves_since_flush = 0
|
|
195
|
+
self._flush()
|
|
196
|
+
|
|
197
|
+
def set_problems(self, records: Sequence[ExitRecord]) -> None:
|
|
198
|
+
"""Mirror ExitStatus.records into the snapshot (usually before finalize)."""
|
|
199
|
+
self._problems = [{'code': record.code, 'reason': record.reason} for record in records]
|
|
200
|
+
|
|
201
|
+
def finalize(self, status: str, exit_code: int) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Write the terminal snapshot.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
status: HEARTBEAT_STATUS_COMPLETED or HEARTBEAT_STATUS_INTERRUPTED.
|
|
207
|
+
exit_code: Same code the process will exit with.
|
|
208
|
+
"""
|
|
209
|
+
if not self._started or self._final:
|
|
210
|
+
return
|
|
211
|
+
if status not in (HEARTBEAT_STATUS_COMPLETED, HEARTBEAT_STATUS_INTERRUPTED):
|
|
212
|
+
status = HEARTBEAT_STATUS_COMPLETED
|
|
213
|
+
self._status = status
|
|
214
|
+
self._exit_code = exit_code
|
|
215
|
+
self._current_file = None
|
|
216
|
+
self._stage = None
|
|
217
|
+
self._final = True
|
|
218
|
+
self._flush()
|
|
219
|
+
|
|
220
|
+
def _files_done_count(self) -> int:
|
|
221
|
+
"""Number of files that already have an outcome."""
|
|
222
|
+
return len(self._done) + len(self._failed)
|
|
223
|
+
|
|
224
|
+
def _leaf_frac(self) -> float:
|
|
225
|
+
if self._leaves_total <= 0:
|
|
226
|
+
return 0.0
|
|
227
|
+
return min(self._leaves_done / self._leaves_total, 1.0)
|
|
228
|
+
|
|
229
|
+
def _percent(self) -> float:
|
|
230
|
+
total = self._files_total
|
|
231
|
+
if total <= 0:
|
|
232
|
+
return 0.0
|
|
233
|
+
return 100.0 * (self._files_done_count() + self._leaf_frac()) / total
|
|
234
|
+
|
|
235
|
+
def _speed_leaves_per_min(self) -> float:
|
|
236
|
+
if self._leaves_done <= 0 or self._stage_started_at <= 0:
|
|
237
|
+
return 0.0
|
|
238
|
+
elapsed = time.monotonic() - self._stage_started_at
|
|
239
|
+
if elapsed <= 0:
|
|
240
|
+
return 0.0
|
|
241
|
+
return self._leaves_done / (elapsed / 60.0)
|
|
242
|
+
|
|
243
|
+
def _elapsed_sec(self) -> float:
|
|
244
|
+
if self._started_at <= 0:
|
|
245
|
+
return 0.0
|
|
246
|
+
return max(0.0, time.monotonic() - self._started_at)
|
|
247
|
+
|
|
248
|
+
def _snapshot(self) -> Dict[str, Any]:
|
|
249
|
+
return {
|
|
250
|
+
'schema_version': HEARTBEAT_SCHEMA_VERSION,
|
|
251
|
+
'status': self._status,
|
|
252
|
+
'exit_code': self._exit_code,
|
|
253
|
+
'updated_at': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
254
|
+
'elapsed_sec': round(self._elapsed_sec(), 3),
|
|
255
|
+
'target': dict(self._target),
|
|
256
|
+
'profile': dict(self._profile),
|
|
257
|
+
'stages': list(self._stages),
|
|
258
|
+
'glossary': dict(self._glossary),
|
|
259
|
+
'progress': {
|
|
260
|
+
'percent': round(self._percent(), 2),
|
|
261
|
+
'files_done': self._files_done_count(),
|
|
262
|
+
'files_total': self._files_total,
|
|
263
|
+
'file_index': self._file_index,
|
|
264
|
+
'current_file': self._current_file,
|
|
265
|
+
'stage': self._stage,
|
|
266
|
+
'leaves_done': self._leaves_done,
|
|
267
|
+
'leaves_total': self._leaves_total,
|
|
268
|
+
'speed_leaves_per_min': round(self._speed_leaves_per_min(), 2),
|
|
269
|
+
},
|
|
270
|
+
'files': {
|
|
271
|
+
'done': list(self._done),
|
|
272
|
+
'failed': [dict(item) for item in self._failed],
|
|
273
|
+
'pending': list(self._pending),
|
|
274
|
+
},
|
|
275
|
+
'problems': [dict(item) for item in self._problems],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def _flush(self) -> None:
|
|
279
|
+
"""Atomically rewrite progress_state.json; never raise into the pipeline."""
|
|
280
|
+
try:
|
|
281
|
+
os.makedirs(self._cache_dir, exist_ok=True)
|
|
282
|
+
payload = json.dumps(self._snapshot(), ensure_ascii=False, separators=(',', ':'))
|
|
283
|
+
wrote = safe_write_file(
|
|
284
|
+
self._path, payload, 'heartbeat progress state',
|
|
285
|
+
self._file_ops, self._logger,
|
|
286
|
+
)
|
|
287
|
+
if not wrote:
|
|
288
|
+
self._logger.warning(
|
|
289
|
+
f"Heartbeat write to {self._path} failed; continuing the run"
|
|
290
|
+
)
|
|
291
|
+
except FileOperationError as e:
|
|
292
|
+
self._logger.warning(
|
|
293
|
+
f"Heartbeat write to {self._path} failed ({e}); continuing the run"
|
|
294
|
+
)
|
|
295
|
+
except OSError as e:
|
|
296
|
+
self._logger.warning(
|
|
297
|
+
f"Heartbeat write to {self._path} failed ({e}); continuing the run"
|
|
298
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
The 'init' command: commented templates of conductor.toml and llm.toml.
|
|
4
|
+
|
|
5
|
+
Templates are rendered from the schema (llm_conductor/schema.py), so every key the
|
|
6
|
+
loader accepts appears in them with its description and default.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from llm_conductor.core.io import FileOperations, safe_write_file
|
|
14
|
+
from llm_conductor.exceptions import ConfigValidationError, FileOperationError
|
|
15
|
+
from llm_conductor.interfaces import FileOperationsProtocol
|
|
16
|
+
from llm_conductor.schema import CONDUCTOR_SCHEMA, LLM_SCHEMA, FileSchema, Setting
|
|
17
|
+
|
|
18
|
+
_LITERAL_DELIMITER = "'''"
|
|
19
|
+
_DEL = "\x7f"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_toml_value(value: Any) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Render a scalar or a list as a TOML value.
|
|
25
|
+
|
|
26
|
+
Strings become basic (double-quoted) strings: JSON escapes are a subset
|
|
27
|
+
of TOML ones, and DEL, which JSON leaves raw but TOML forbids, is
|
|
28
|
+
escaped separately.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
value: bool, int, float, str, or a list/tuple of those.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The TOML representation.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
TypeError: For any other type (tables are rendered by the caller).
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return "true" if value else "false"
|
|
41
|
+
if isinstance(value, int):
|
|
42
|
+
return str(value)
|
|
43
|
+
if isinstance(value, float):
|
|
44
|
+
if math.isnan(value):
|
|
45
|
+
return "nan"
|
|
46
|
+
if math.isinf(value):
|
|
47
|
+
return "inf" if value > 0 else "-inf"
|
|
48
|
+
return repr(value)
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return json.dumps(value, ensure_ascii=False).replace(_DEL, "\\u007f")
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
return "[" + ", ".join(format_toml_value(item) for item in value) + "]"
|
|
53
|
+
raise TypeError(f"Cannot render {type(value).__name__} as a TOML value")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_toml_multiline(value: str) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Render a string as a multi-line literal, kept byte for byte without escaping.
|
|
59
|
+
|
|
60
|
+
Falls back to a basic string when a literal cannot hold the text
|
|
61
|
+
(it contains ''' or control characters other than newline and tab, or
|
|
62
|
+
ends with a quote that would merge with the closing delimiter).
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
value: The string.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The TOML representation.
|
|
69
|
+
"""
|
|
70
|
+
has_control = any((ch < " " or ch == _DEL) and ch not in "\n\t" for ch in value)
|
|
71
|
+
if _LITERAL_DELIMITER in value or has_control or value.endswith("'"):
|
|
72
|
+
return format_toml_value(value)
|
|
73
|
+
# The newline right after the opening delimiter is trimmed by TOML parsers.
|
|
74
|
+
return f"{_LITERAL_DELIMITER}\n{value}{_LITERAL_DELIMITER}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TomlTemplateRenderer:
|
|
78
|
+
"""Renders a FileSchema as a commented TOML file filled with the defaults."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, schema: FileSchema):
|
|
81
|
+
"""
|
|
82
|
+
Args:
|
|
83
|
+
schema: Schema of the file to render.
|
|
84
|
+
"""
|
|
85
|
+
self._schema = schema
|
|
86
|
+
|
|
87
|
+
def render(self) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Build the template text.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A TOML document that parses back to the schema defaults.
|
|
93
|
+
"""
|
|
94
|
+
lines: List[str] = self._comment(self._schema.header)
|
|
95
|
+
lines += self._comment("Generated by `llm-conductor init`. Absent keys take the defaults below; "
|
|
96
|
+
"unknown keys are an error.")
|
|
97
|
+
for section in self._schema.sections:
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines += self._comment(section.description)
|
|
100
|
+
lines.append(f"[{section.name}]")
|
|
101
|
+
for setting in section.settings:
|
|
102
|
+
lines += self._render_setting(setting)
|
|
103
|
+
return "\n".join(lines) + "\n"
|
|
104
|
+
|
|
105
|
+
def _render_setting(self, setting: Setting) -> List[str]:
|
|
106
|
+
"""Comment line(s) and the key line of one setting."""
|
|
107
|
+
value = setting.init_value
|
|
108
|
+
if setting.multiline:
|
|
109
|
+
return self._comment(setting.description) + [
|
|
110
|
+
f"{setting.key} = {format_toml_multiline(value)}"]
|
|
111
|
+
rendered = format_toml_value(value)
|
|
112
|
+
# Comment shows the absent-key default; the line may use template_default.
|
|
113
|
+
default_note = format_toml_value(setting.default)
|
|
114
|
+
return self._comment(f"{setting.description} Default: {default_note}") + [
|
|
115
|
+
f"{setting.key} = {rendered}"]
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _comment(text: str) -> List[str]:
|
|
119
|
+
"""Turn text into '# '-prefixed lines."""
|
|
120
|
+
return [f"# {line}" if line else "#" for line in text.split("\n")]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_config_files(conductor_path: str, llm_path: str,
|
|
124
|
+
file_ops: Optional[FileOperationsProtocol] = None) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Write fresh conductor.toml and llm.toml templates.
|
|
127
|
+
|
|
128
|
+
Existing files are never overwritten: a configured profile may hold
|
|
129
|
+
hand-tuned prompts and keys.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
conductor_path: Destination of conductor.toml.
|
|
133
|
+
llm_path: Destination of llm.toml.
|
|
134
|
+
file_ops: File operations for writing; default FileOperations.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ConfigValidationError: If either file already exists.
|
|
138
|
+
FileOperationError: If a file cannot be written.
|
|
139
|
+
"""
|
|
140
|
+
targets: Tuple[Tuple[str, FileSchema], ...] = ((conductor_path, CONDUCTOR_SCHEMA), (llm_path, LLM_SCHEMA))
|
|
141
|
+
existing = [path for path, _ in targets if os.path.exists(path)]
|
|
142
|
+
if existing:
|
|
143
|
+
raise ConfigValidationError(
|
|
144
|
+
f"Refusing to overwrite existing configuration: {', '.join(existing)}. "
|
|
145
|
+
f"Remove or rename the file(s) to generate fresh templates.")
|
|
146
|
+
|
|
147
|
+
writer = file_ops or FileOperations()
|
|
148
|
+
for path, schema in targets:
|
|
149
|
+
text = TomlTemplateRenderer(schema).render()
|
|
150
|
+
if not safe_write_file(path, text, f"{schema.file_name} template", writer):
|
|
151
|
+
raise FileOperationError(f"Failed to write {path}")
|
|
152
|
+
print(f"Created {path}")
|
|
153
|
+
|
|
154
|
+
print("For OpenAI-compatible APIs (OpenAI, DeepSeek, OpenRouter), edit llm.toml:")
|
|
155
|
+
print(" - set type = \"openai\" and url in [translation.api] / [verification.api]")
|
|
156
|
+
print(" - set key_env to the environment variable holding the key (or key to the key itself)")
|