llm-conductor 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- llm_conductor-1.0.0/PKG-INFO +176 -0
- llm_conductor-1.0.0/README.md +135 -0
- llm_conductor-1.0.0/llm_conductor/__init__.py +4 -0
- llm_conductor-1.0.0/llm_conductor/__main__.py +7 -0
- llm_conductor-1.0.0/llm_conductor/cli/__init__.py +0 -0
- llm_conductor-1.0.0/llm_conductor/cli/exit_status.py +84 -0
- llm_conductor-1.0.0/llm_conductor/cli/glossary.py +94 -0
- llm_conductor-1.0.0/llm_conductor/cli/heartbeat.py +298 -0
- llm_conductor-1.0.0/llm_conductor/cli/init.py +156 -0
- llm_conductor-1.0.0/llm_conductor/cli/locations.py +80 -0
- llm_conductor-1.0.0/llm_conductor/cli/main.py +227 -0
- llm_conductor-1.0.0/llm_conductor/cli/runner.py +518 -0
- llm_conductor-1.0.0/llm_conductor/cli/targets.py +350 -0
- llm_conductor-1.0.0/llm_conductor/cli/validators.py +283 -0
- llm_conductor-1.0.0/llm_conductor/config.py +1395 -0
- llm_conductor-1.0.0/llm_conductor/constants.py +402 -0
- llm_conductor-1.0.0/llm_conductor/core/__init__.py +0 -0
- llm_conductor-1.0.0/llm_conductor/core/api_client.py +811 -0
- llm_conductor-1.0.0/llm_conductor/core/cache.py +924 -0
- llm_conductor-1.0.0/llm_conductor/core/circuit_breaker.py +64 -0
- llm_conductor-1.0.0/llm_conductor/core/io.py +484 -0
- llm_conductor-1.0.0/llm_conductor/core/locking.py +187 -0
- llm_conductor-1.0.0/llm_conductor/core/metrics.py +158 -0
- llm_conductor-1.0.0/llm_conductor/core/shutdown.py +159 -0
- llm_conductor-1.0.0/llm_conductor/exceptions.py +83 -0
- llm_conductor-1.0.0/llm_conductor/interfaces.py +357 -0
- llm_conductor-1.0.0/llm_conductor/l10n/__init__.py +0 -0
- llm_conductor-1.0.0/llm_conductor/l10n/context.py +771 -0
- llm_conductor-1.0.0/llm_conductor/l10n/processor.py +1111 -0
- llm_conductor-1.0.0/llm_conductor/l10n/validation.py +188 -0
- llm_conductor-1.0.0/llm_conductor/schema.py +563 -0
- llm_conductor-1.0.0/llm_conductor/utils.py +317 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/PKG-INFO +176 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/SOURCES.txt +39 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/dependency_links.txt +1 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/entry_points.txt +2 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/requires.txt +13 -0
- llm_conductor-1.0.0/llm_conductor.egg-info/top_level.txt +1 -0
- llm_conductor-1.0.0/pyproject.toml +71 -0
- llm_conductor-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Necrofox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-conductor
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI pipeline for localizing nested JSON with Ollama or OpenAI-compatible LLMs
|
|
5
|
+
Author: Necrofox
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Necrofox/llm-conductor
|
|
8
|
+
Project-URL: Repository, https://github.com/Necrofox/llm-conductor
|
|
9
|
+
Project-URL: Issues, https://github.com/Necrofox/llm-conductor/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Necrofox/llm-conductor/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: localization,i18n,l10n,llm,ollama,openai,json,translation-pipeline
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Internationalization
|
|
22
|
+
Classifier: Topic :: Software Development :: Localization
|
|
23
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: requests<3.0.0,>=2.31.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pyinstaller>=6.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
|
|
34
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
35
|
+
Requires-Dist: hypothesis>=6.100.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
37
|
+
Requires-Dist: types-requests>=2.31.0; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
39
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# LLM-Conductor
|
|
43
|
+
|
|
44
|
+
**CLI pipeline** for localizing nested JSON string resources with language
|
|
45
|
+
models (Ollama or any OpenAI-compatible API). Optional second stage
|
|
46
|
+
proofreads with a separate endpoint.
|
|
47
|
+
|
|
48
|
+
This is **not** a local machine-translation product, offline dictionary, or
|
|
49
|
+
GUI translator. There is no built-in MT engine — you supply the model and the
|
|
50
|
+
prompts; quality follows them. Think “batch orchestration for LLM
|
|
51
|
+
localization,” not “install and translate offline.”
|
|
52
|
+
|
|
53
|
+
**Language pair = prompt only.** The engine does not hard-code source or
|
|
54
|
+
target languages. Name them in `llm.toml`. The default `init` profile is one
|
|
55
|
+
working example (often English→another language), not a product limit.
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- Recursive JSON of any depth; non-strings left untouched
|
|
60
|
+
- Per-string isolation (no batching unrelated keys into one prompt)
|
|
61
|
+
- Key-name linguistic hints (`_male` / `_female` / `_plural` / …)
|
|
62
|
+
- Glossary injection, formatting-tag masking, disk cache + in-run dedup
|
|
63
|
+
- Reply validation, consecutive-failure breaker, OS locks, progress heartbeat
|
|
64
|
+
- Independent transports for translation vs verification
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
### 1. Install or grab a binary
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e ".[dev]" # from a clone
|
|
72
|
+
# or download llm-conductor.exe from GitHub Releases (when published)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
PyPI upload is prepared (packaging metadata is in place) but may not be live
|
|
76
|
+
yet — prefer a Release `.exe`, `pip install` from a clone, or
|
|
77
|
+
`pip install git+https://github.com/Necrofox/llm-conductor.git` once the
|
|
78
|
+
repo is public.
|
|
79
|
+
|
|
80
|
+
### 2. Configs next to the program
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
llm-conductor init # conductor.toml + llm.toml
|
|
84
|
+
# portable checkout:
|
|
85
|
+
python llm-conductor.py init
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Configs resolve **beside the entry point** (exe / wrapper / installed shim),
|
|
89
|
+
not cwd. Edit `llm.toml` for your endpoint and prompts; set
|
|
90
|
+
`[validation].allowed_scripts` for the target writing system (e.g.
|
|
91
|
+
`["cyrillic"]`, `["latin"]`, `["cjk"]`). Prefer `key_env` over literal keys.
|
|
92
|
+
|
|
93
|
+
### 3. Run
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
llm-conductor run --target path/to/strings.json
|
|
97
|
+
llm-conductor run --target path/to/strings.json --stage translate --strict
|
|
98
|
+
llm-conductor run --target project/ui.list --result2target
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Results land under `result/<target-name>/` (`translated/`, `redacted/`,
|
|
102
|
+
`final/`) unless `--result2target`. See [doc/cli.md](doc/cli.md).
|
|
103
|
+
|
|
104
|
+
### Ollama vs OpenAI-compatible
|
|
105
|
+
|
|
106
|
+
| | Ollama | OpenAI-compatible |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `type` | `ollama` | `openai` |
|
|
109
|
+
| URL | `…/api/chat` | `…/v1/chat/completions` |
|
|
110
|
+
| Key | ignored | `key_env` / `key` required |
|
|
111
|
+
|
|
112
|
+
Point translation and verification at different hosts if you want.
|
|
113
|
+
|
|
114
|
+
## Example
|
|
115
|
+
|
|
116
|
+
Input (see [examples/sample.json](examples/sample.json)):
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"npc": {
|
|
121
|
+
"greeting_male": "Welcome, traveler.",
|
|
122
|
+
"greeting_female": "Welcome, traveler."
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Same English text, different key suffixes → separate requests and (usually)
|
|
128
|
+
different grammatical forms in the target language. Full prompt rules:
|
|
129
|
+
[doc/prompts.md](doc/prompts.md).
|
|
130
|
+
|
|
131
|
+
## Key-name conventions
|
|
132
|
+
|
|
133
|
+
| Pattern | Hint |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `*_male`, `*_female`, `*_neutral` | gender |
|
|
136
|
+
| `*_plural`, `*_singular`, `*_few`, `*_zero`, `count_*` | number |
|
|
137
|
+
| `*_formal`, `*_informal` | formality |
|
|
138
|
+
|
|
139
|
+
## Documentation
|
|
140
|
+
|
|
141
|
+
| Doc | Topic |
|
|
142
|
+
|---|---|
|
|
143
|
+
| [doc/concepts.md](doc/concepts.md) | What it is / is not; isolation; language=prompt |
|
|
144
|
+
| [doc/configuration.md](doc/configuration.md) | TOML split, paths, secrets |
|
|
145
|
+
| [doc/cli.md](doc/cli.md) | Commands, results, exit codes |
|
|
146
|
+
| [doc/prompts.md](doc/prompts.md) | Authoring prompts |
|
|
147
|
+
| [doc/pipeline.md](doc/pipeline.md) | Cache, validation, resilience |
|
|
148
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup and conventions |
|
|
149
|
+
| [AGENTS.md](AGENTS.md) | Notes for AI coding agents |
|
|
150
|
+
|
|
151
|
+
## Cache note
|
|
152
|
+
|
|
153
|
+
Rejected model replies are not cached. A warm-cache rerun that only hits
|
|
154
|
+
residual misses can exit with code `5` even if an earlier full run was `0`.
|
|
155
|
+
Fix prompts/model or remeasure with `--no-cache` ([doc/cli.md](doc/cli.md)).
|
|
156
|
+
|
|
157
|
+
## Roadmap (high level)
|
|
158
|
+
|
|
159
|
+
- Richer input formats (e.g. subtitle adapters)
|
|
160
|
+
- Optional in-stage parallelism with single-flight on the full cache key
|
|
161
|
+
- Stage-local resume orthogonal to the LLM cache (concept)
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT — see [LICENSE](LICENSE).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Кратко (RU)
|
|
170
|
+
|
|
171
|
+
**LLM-Conductor** — CLI-конвейер: гоняет строковые листья JSON через LLM
|
|
172
|
+
(Ollama / OpenAI-совместимые API), опционально второй моделью вычитывает
|
|
173
|
+
перевод. Это **не** локальный переводчик и не движок MT — модель и промпты
|
|
174
|
+
ваши. Пара языков задаётся только текстом промпта в `llm.toml`, не схемой
|
|
175
|
+
движка. Быстрый старт: `init` → правки профиля → `run --target …`. Документация
|
|
176
|
+
на английском в `doc/`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# LLM-Conductor
|
|
2
|
+
|
|
3
|
+
**CLI pipeline** for localizing nested JSON string resources with language
|
|
4
|
+
models (Ollama or any OpenAI-compatible API). Optional second stage
|
|
5
|
+
proofreads with a separate endpoint.
|
|
6
|
+
|
|
7
|
+
This is **not** a local machine-translation product, offline dictionary, or
|
|
8
|
+
GUI translator. There is no built-in MT engine — you supply the model and the
|
|
9
|
+
prompts; quality follows them. Think “batch orchestration for LLM
|
|
10
|
+
localization,” not “install and translate offline.”
|
|
11
|
+
|
|
12
|
+
**Language pair = prompt only.** The engine does not hard-code source or
|
|
13
|
+
target languages. Name them in `llm.toml`. The default `init` profile is one
|
|
14
|
+
working example (often English→another language), not a product limit.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Recursive JSON of any depth; non-strings left untouched
|
|
19
|
+
- Per-string isolation (no batching unrelated keys into one prompt)
|
|
20
|
+
- Key-name linguistic hints (`_male` / `_female` / `_plural` / …)
|
|
21
|
+
- Glossary injection, formatting-tag masking, disk cache + in-run dedup
|
|
22
|
+
- Reply validation, consecutive-failure breaker, OS locks, progress heartbeat
|
|
23
|
+
- Independent transports for translation vs verification
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### 1. Install or grab a binary
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e ".[dev]" # from a clone
|
|
31
|
+
# or download llm-conductor.exe from GitHub Releases (when published)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
PyPI upload is prepared (packaging metadata is in place) but may not be live
|
|
35
|
+
yet — prefer a Release `.exe`, `pip install` from a clone, or
|
|
36
|
+
`pip install git+https://github.com/Necrofox/llm-conductor.git` once the
|
|
37
|
+
repo is public.
|
|
38
|
+
|
|
39
|
+
### 2. Configs next to the program
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
llm-conductor init # conductor.toml + llm.toml
|
|
43
|
+
# portable checkout:
|
|
44
|
+
python llm-conductor.py init
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Configs resolve **beside the entry point** (exe / wrapper / installed shim),
|
|
48
|
+
not cwd. Edit `llm.toml` for your endpoint and prompts; set
|
|
49
|
+
`[validation].allowed_scripts` for the target writing system (e.g.
|
|
50
|
+
`["cyrillic"]`, `["latin"]`, `["cjk"]`). Prefer `key_env` over literal keys.
|
|
51
|
+
|
|
52
|
+
### 3. Run
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
llm-conductor run --target path/to/strings.json
|
|
56
|
+
llm-conductor run --target path/to/strings.json --stage translate --strict
|
|
57
|
+
llm-conductor run --target project/ui.list --result2target
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Results land under `result/<target-name>/` (`translated/`, `redacted/`,
|
|
61
|
+
`final/`) unless `--result2target`. See [doc/cli.md](doc/cli.md).
|
|
62
|
+
|
|
63
|
+
### Ollama vs OpenAI-compatible
|
|
64
|
+
|
|
65
|
+
| | Ollama | OpenAI-compatible |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `type` | `ollama` | `openai` |
|
|
68
|
+
| URL | `…/api/chat` | `…/v1/chat/completions` |
|
|
69
|
+
| Key | ignored | `key_env` / `key` required |
|
|
70
|
+
|
|
71
|
+
Point translation and verification at different hosts if you want.
|
|
72
|
+
|
|
73
|
+
## Example
|
|
74
|
+
|
|
75
|
+
Input (see [examples/sample.json](examples/sample.json)):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"npc": {
|
|
80
|
+
"greeting_male": "Welcome, traveler.",
|
|
81
|
+
"greeting_female": "Welcome, traveler."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Same English text, different key suffixes → separate requests and (usually)
|
|
87
|
+
different grammatical forms in the target language. Full prompt rules:
|
|
88
|
+
[doc/prompts.md](doc/prompts.md).
|
|
89
|
+
|
|
90
|
+
## Key-name conventions
|
|
91
|
+
|
|
92
|
+
| Pattern | Hint |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `*_male`, `*_female`, `*_neutral` | gender |
|
|
95
|
+
| `*_plural`, `*_singular`, `*_few`, `*_zero`, `count_*` | number |
|
|
96
|
+
| `*_formal`, `*_informal` | formality |
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
| Doc | Topic |
|
|
101
|
+
|---|---|
|
|
102
|
+
| [doc/concepts.md](doc/concepts.md) | What it is / is not; isolation; language=prompt |
|
|
103
|
+
| [doc/configuration.md](doc/configuration.md) | TOML split, paths, secrets |
|
|
104
|
+
| [doc/cli.md](doc/cli.md) | Commands, results, exit codes |
|
|
105
|
+
| [doc/prompts.md](doc/prompts.md) | Authoring prompts |
|
|
106
|
+
| [doc/pipeline.md](doc/pipeline.md) | Cache, validation, resilience |
|
|
107
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup and conventions |
|
|
108
|
+
| [AGENTS.md](AGENTS.md) | Notes for AI coding agents |
|
|
109
|
+
|
|
110
|
+
## Cache note
|
|
111
|
+
|
|
112
|
+
Rejected model replies are not cached. A warm-cache rerun that only hits
|
|
113
|
+
residual misses can exit with code `5` even if an earlier full run was `0`.
|
|
114
|
+
Fix prompts/model or remeasure with `--no-cache` ([doc/cli.md](doc/cli.md)).
|
|
115
|
+
|
|
116
|
+
## Roadmap (high level)
|
|
117
|
+
|
|
118
|
+
- Richer input formats (e.g. subtitle adapters)
|
|
119
|
+
- Optional in-stage parallelism with single-flight on the full cache key
|
|
120
|
+
- Stage-local resume orthogonal to the LLM cache (concept)
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT — see [LICENSE](LICENSE).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Кратко (RU)
|
|
129
|
+
|
|
130
|
+
**LLM-Conductor** — CLI-конвейер: гоняет строковые листья JSON через LLM
|
|
131
|
+
(Ollama / OpenAI-совместимые API), опционально второй моделью вычитывает
|
|
132
|
+
перевод. Это **не** локальный переводчик и не движок MT — модель и промпты
|
|
133
|
+
ваши. Пара языков задаётся только текстом промпта в `llm.toml`, не схемой
|
|
134
|
+
движка. Быстрый старт: `init` → правки профиля → `run --target …`. Документация
|
|
135
|
+
на английском в `doc/`.
|
|
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)
|