ffai-cli 0.2.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.
- ffai/__init__.py +5 -0
- ffai/__main__.py +5 -0
- ffai/cli.py +97 -0
- ffai/command_builder.py +287 -0
- ffai/config.py +251 -0
- ffai/errors.py +31 -0
- ffai/executor.py +255 -0
- ffai/i18n.py +400 -0
- ffai/llm.py +181 -0
- ffai/models.py +454 -0
- ffai/pandoc_builder.py +254 -0
- ffai/parser.py +112 -0
- ffai/prompt.py +296 -0
- ffai/providers.py +122 -0
- ffai/repl.py +933 -0
- ffai/state.py +291 -0
- ffai/tools.py +177 -0
- ffai/utils.py +91 -0
- ffai_cli-0.2.0.dist-info/METADATA +620 -0
- ffai_cli-0.2.0.dist-info/RECORD +24 -0
- ffai_cli-0.2.0.dist-info/WHEEL +5 -0
- ffai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ffai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ffai_cli-0.2.0.dist-info/top_level.txt +1 -0
ffai/llm.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM client and strict decision parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openai import (
|
|
9
|
+
APIConnectionError,
|
|
10
|
+
APIStatusError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BadRequestError,
|
|
13
|
+
OpenAI,
|
|
14
|
+
OpenAIError,
|
|
15
|
+
)
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from ffai.config import LLMConfig
|
|
19
|
+
from ffai.errors import LLMError
|
|
20
|
+
from ffai.models import LLMDecision
|
|
21
|
+
from ffai.prompt import SYSTEM_PROMPT, build_user_prompt
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_json_object(text: str) -> str:
|
|
25
|
+
"""Extract the first balanced JSON object from model output.
|
|
26
|
+
|
|
27
|
+
Models occasionally wrap JSON in code fences or a short preface even when
|
|
28
|
+
instructed not to. Braces inside JSON strings are ignored.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
start = text.find("{")
|
|
32
|
+
if start < 0:
|
|
33
|
+
raise LLMError("The model did not return a JSON object")
|
|
34
|
+
|
|
35
|
+
depth = 0
|
|
36
|
+
in_string = False
|
|
37
|
+
escaped = False
|
|
38
|
+
for index in range(start, len(text)):
|
|
39
|
+
char = text[index]
|
|
40
|
+
if in_string:
|
|
41
|
+
if escaped:
|
|
42
|
+
escaped = False
|
|
43
|
+
elif char == "\\":
|
|
44
|
+
escaped = True
|
|
45
|
+
elif char == '"':
|
|
46
|
+
in_string = False
|
|
47
|
+
continue
|
|
48
|
+
if char == '"':
|
|
49
|
+
in_string = True
|
|
50
|
+
elif char == "{":
|
|
51
|
+
depth += 1
|
|
52
|
+
elif char == "}":
|
|
53
|
+
depth -= 1
|
|
54
|
+
if depth == 0:
|
|
55
|
+
return text[start : index + 1]
|
|
56
|
+
raise LLMError("The model returned an incomplete JSON object")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_decision(text: str) -> LLMDecision:
|
|
60
|
+
raw = extract_json_object(text)
|
|
61
|
+
try:
|
|
62
|
+
payload: Any = json.loads(raw)
|
|
63
|
+
except json.JSONDecodeError as exc:
|
|
64
|
+
raise LLMError(f"The model returned invalid JSON: {exc}") from exc
|
|
65
|
+
if not isinstance(payload, dict):
|
|
66
|
+
raise LLMError("The model response must be a JSON object")
|
|
67
|
+
try:
|
|
68
|
+
return LLMDecision.model_validate(payload)
|
|
69
|
+
except ValidationError as exc:
|
|
70
|
+
errors = "; ".join(
|
|
71
|
+
f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}"
|
|
72
|
+
for error in exc.errors()
|
|
73
|
+
)
|
|
74
|
+
raise LLMError(f"The model returned an invalid task decision: {errors}") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LLMClient:
|
|
78
|
+
"""Thin wrapper around an OpenAI-compatible chat-completions endpoint."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: LLMConfig, *, client: Any | None = None) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
if client is None:
|
|
83
|
+
try:
|
|
84
|
+
client = OpenAI(
|
|
85
|
+
api_key=config.api_key.get_secret_value(),
|
|
86
|
+
base_url=config.base_url,
|
|
87
|
+
timeout=60.0,
|
|
88
|
+
max_retries=2,
|
|
89
|
+
)
|
|
90
|
+
except OpenAIError as exc:
|
|
91
|
+
raise LLMError(f"Could not initialize the API client: {exc}") from exc
|
|
92
|
+
self._client = client
|
|
93
|
+
|
|
94
|
+
def analyze(self, state: dict[str, Any], request: str) -> LLMDecision:
|
|
95
|
+
messages: list[dict[str, str]] = [
|
|
96
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
97
|
+
{"role": "user", "content": build_user_prompt(state, request)},
|
|
98
|
+
]
|
|
99
|
+
json_mode = True
|
|
100
|
+
try:
|
|
101
|
+
raw = self._complete(messages, json_mode=True, allow_bad_request=True)
|
|
102
|
+
except BadRequestError:
|
|
103
|
+
# Some OpenAI-compatible servers do not implement JSON mode. The
|
|
104
|
+
# prompt still demands JSON, and parse_decision validates it, so a
|
|
105
|
+
# single fallback is safe.
|
|
106
|
+
json_mode = False
|
|
107
|
+
raw = self._complete(messages, json_mode=False)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return parse_decision(raw)
|
|
111
|
+
except LLMError as first_error:
|
|
112
|
+
repair_messages = [
|
|
113
|
+
*messages,
|
|
114
|
+
{"role": "assistant", "content": raw[:8_000]},
|
|
115
|
+
{
|
|
116
|
+
"role": "user",
|
|
117
|
+
"content": (
|
|
118
|
+
"Your previous response failed validation: "
|
|
119
|
+
f"{first_error}. Return exactly one corrected JSON object "
|
|
120
|
+
"matching the contract. Do not add commentary or markdown."
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
repaired = self._complete(repair_messages, json_mode=json_mode)
|
|
125
|
+
try:
|
|
126
|
+
return parse_decision(repaired)
|
|
127
|
+
except LLMError as second_error:
|
|
128
|
+
raise LLMError(
|
|
129
|
+
f"The model response could not be validated after one repair attempt: "
|
|
130
|
+
f"{second_error}"
|
|
131
|
+
) from second_error
|
|
132
|
+
|
|
133
|
+
def _complete(
|
|
134
|
+
self,
|
|
135
|
+
messages: list[dict[str, str]],
|
|
136
|
+
*,
|
|
137
|
+
json_mode: bool,
|
|
138
|
+
allow_bad_request: bool = False,
|
|
139
|
+
) -> str:
|
|
140
|
+
kwargs: dict[str, Any] = {
|
|
141
|
+
"model": self._config.model,
|
|
142
|
+
"messages": messages,
|
|
143
|
+
"temperature": 0.1,
|
|
144
|
+
}
|
|
145
|
+
if json_mode:
|
|
146
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
147
|
+
try:
|
|
148
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
149
|
+
except AuthenticationError as exc:
|
|
150
|
+
raise LLMError(
|
|
151
|
+
"API authentication failed. Check OPENAI_API_KEY and OPENAI_BASE_URL."
|
|
152
|
+
) from exc
|
|
153
|
+
except BadRequestError as exc:
|
|
154
|
+
if allow_bad_request:
|
|
155
|
+
raise
|
|
156
|
+
raise LLMError(
|
|
157
|
+
"The API rejected the request (HTTP 400). Check OPENAI_MODEL and the endpoint."
|
|
158
|
+
) from exc
|
|
159
|
+
except APIConnectionError as exc:
|
|
160
|
+
raise LLMError(
|
|
161
|
+
"Could not reach the API endpoint. Check OPENAI_BASE_URL and your network."
|
|
162
|
+
) from exc
|
|
163
|
+
except APIStatusError as exc:
|
|
164
|
+
raise LLMError(
|
|
165
|
+
f"The API returned HTTP {exc.status_code}: {_short_api_message(exc)}"
|
|
166
|
+
) from exc
|
|
167
|
+
except OpenAIError as exc:
|
|
168
|
+
raise LLMError(f"The API request failed: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
content = response.choices[0].message.content
|
|
172
|
+
except (AttributeError, IndexError) as exc:
|
|
173
|
+
raise LLMError("The API returned an empty or malformed response") from exc
|
|
174
|
+
if not isinstance(content, str) or not content.strip():
|
|
175
|
+
raise LLMError("The model returned no content")
|
|
176
|
+
return content
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _short_api_message(exc: APIStatusError) -> str:
|
|
180
|
+
message = getattr(exc, "message", None) or str(exc)
|
|
181
|
+
return message.strip()[:500]
|
ffai/models.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Pydantic models for conversion tasks and LLM decisions.
|
|
2
|
+
|
|
3
|
+
The language model never receives or produces a shell command. It may only
|
|
4
|
+
produce a :class:`ToolPatch`; the patch is validated here and applied to the
|
|
5
|
+
locally owned task state. Each supported external tool owns its own task model
|
|
6
|
+
and patch model, and a patch always targets exactly one tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
StringConstraints,
|
|
18
|
+
field_validator,
|
|
19
|
+
model_validator,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ToolName = Literal["ffmpeg", "pandoc"]
|
|
23
|
+
|
|
24
|
+
# Codec identifiers are flag values, not shell fragments. Restricting the
|
|
25
|
+
# first character also keeps a model-provided value from looking like an
|
|
26
|
+
# FFmpeg option.
|
|
27
|
+
CodecName = Annotated[
|
|
28
|
+
str,
|
|
29
|
+
StringConstraints(
|
|
30
|
+
strip_whitespace=True,
|
|
31
|
+
min_length=1,
|
|
32
|
+
max_length=64,
|
|
33
|
+
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$",
|
|
34
|
+
),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
Bitrate = Annotated[
|
|
38
|
+
str,
|
|
39
|
+
StringConstraints(
|
|
40
|
+
strip_whitespace=True,
|
|
41
|
+
min_length=1,
|
|
42
|
+
max_length=16,
|
|
43
|
+
pattern=r"^\d+(?:\.\d+)?[kKmMgG]?$",
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
Preset = Annotated[
|
|
48
|
+
str,
|
|
49
|
+
StringConstraints(
|
|
50
|
+
strip_whitespace=True,
|
|
51
|
+
min_length=1,
|
|
52
|
+
max_length=48,
|
|
53
|
+
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
FilterExpression = Annotated[
|
|
58
|
+
str,
|
|
59
|
+
StringConstraints(strip_whitespace=True, min_length=1, max_length=20_000),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
StreamMap = Annotated[
|
|
63
|
+
str,
|
|
64
|
+
StringConstraints(
|
|
65
|
+
strip_whitespace=True,
|
|
66
|
+
min_length=1,
|
|
67
|
+
max_length=256,
|
|
68
|
+
pattern=r"^(?:\[[^\[\]\s,]+\]|\d+(?::[^:\s]+){0,4}\??)$",
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
ExtraOption = Annotated[
|
|
73
|
+
str,
|
|
74
|
+
StringConstraints(min_length=1, max_length=2_048),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
Description = Annotated[str, StringConstraints(max_length=4_000)]
|
|
78
|
+
|
|
79
|
+
# Pandoc format names are single tokens such as ``gfm``, ``markdown_strict``
|
|
80
|
+
# or ``docx``. The pattern is deliberately permissive about the exact name so
|
|
81
|
+
# newer Pandoc formats keep working; Pandoc itself reports unknown formats.
|
|
82
|
+
FormatName = Annotated[
|
|
83
|
+
str,
|
|
84
|
+
StringConstraints(
|
|
85
|
+
strip_whitespace=True,
|
|
86
|
+
min_length=1,
|
|
87
|
+
max_length=32,
|
|
88
|
+
pattern=r"^[A-Za-z][A-Za-z0-9_+-]*$",
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# Metadata is passed as one ``-M key=value`` argv pair. Only the key is
|
|
93
|
+
# constrained; the value is an opaque argv token that never reaches a shell.
|
|
94
|
+
MetadataEntry = Annotated[
|
|
95
|
+
str,
|
|
96
|
+
StringConstraints(
|
|
97
|
+
strip_whitespace=True,
|
|
98
|
+
min_length=3,
|
|
99
|
+
max_length=2_048,
|
|
100
|
+
pattern=r"^[A-Za-z][A-Za-z0-9_.-]*=.+$",
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# Filesystem inputs to Pandoc (templates, bibliographies, resources). They are
|
|
105
|
+
# validated against the filesystem by the Pandoc command builder; the model
|
|
106
|
+
# only carries the path as one argv token.
|
|
107
|
+
PathValue = Annotated[
|
|
108
|
+
str,
|
|
109
|
+
StringConstraints(strip_whitespace=True, min_length=1, max_length=4_096),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
PDF_ENGINES: tuple[str, ...] = (
|
|
113
|
+
"pdflatex",
|
|
114
|
+
"xelatex",
|
|
115
|
+
"lualatex",
|
|
116
|
+
"latexmk",
|
|
117
|
+
"tectonic",
|
|
118
|
+
"typst",
|
|
119
|
+
"context",
|
|
120
|
+
"wkhtmltopdf",
|
|
121
|
+
"weasyprint",
|
|
122
|
+
"prince",
|
|
123
|
+
"pdfroff",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
HIGHLIGHT_STYLES: tuple[str, ...] = (
|
|
127
|
+
"pygments",
|
|
128
|
+
"tango",
|
|
129
|
+
"espresso",
|
|
130
|
+
"zenburn",
|
|
131
|
+
"kate",
|
|
132
|
+
"monochrome",
|
|
133
|
+
"breezedark",
|
|
134
|
+
"haddock",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _reject_nul(value: str, field_name: str) -> str:
|
|
139
|
+
if "\x00" in value:
|
|
140
|
+
raise ValueError(f"{field_name} must not contain NUL characters")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reject_control_characters(value: str, field_name: str) -> str:
|
|
145
|
+
_reject_nul(value, field_name)
|
|
146
|
+
if "\n" in value or "\r" in value:
|
|
147
|
+
raise ValueError(f"{field_name} must not contain newlines")
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_choice(value: str | None, allowed: tuple[str, ...], field_name: str) -> str | None:
|
|
152
|
+
if value is not None and value not in allowed:
|
|
153
|
+
raise ValueError(f"{field_name} must be one of: {', '.join(allowed)}")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TaskBase(BaseModel):
|
|
158
|
+
"""Fields shared by every external conversion tool.
|
|
159
|
+
|
|
160
|
+
``inputs`` and ``output`` are owned by the REPL commands and are never
|
|
161
|
+
writable through a model patch.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
165
|
+
|
|
166
|
+
inputs: list[str] = Field(default_factory=list)
|
|
167
|
+
output: str | None = None
|
|
168
|
+
description: Description = "New conversion task"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class FFmpegTask(TaskBase):
|
|
172
|
+
"""Canonical, local representation of one FFmpeg operation."""
|
|
173
|
+
|
|
174
|
+
description: Description = "New FFmpeg task"
|
|
175
|
+
|
|
176
|
+
video_filters: list[FilterExpression] = Field(default_factory=list)
|
|
177
|
+
audio_filters: list[FilterExpression] = Field(default_factory=list)
|
|
178
|
+
video_codec: CodecName | None = None
|
|
179
|
+
audio_codec: CodecName | None = None
|
|
180
|
+
video_bitrate: Bitrate | None = None
|
|
181
|
+
audio_bitrate: Bitrate | None = None
|
|
182
|
+
crf: int | None = Field(default=None, ge=0, le=63)
|
|
183
|
+
preset: Preset | None = None
|
|
184
|
+
extra_options: list[ExtraOption] = Field(default_factory=list)
|
|
185
|
+
filter_complex: FilterExpression | None = None
|
|
186
|
+
maps: list[StreamMap] = Field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
@field_validator("video_filters", "audio_filters")
|
|
189
|
+
@classmethod
|
|
190
|
+
def _validate_filters(cls, values: list[str]) -> list[str]:
|
|
191
|
+
for value in values:
|
|
192
|
+
_reject_nul(value, "filter")
|
|
193
|
+
return values
|
|
194
|
+
|
|
195
|
+
@field_validator("extra_options")
|
|
196
|
+
@classmethod
|
|
197
|
+
def _validate_extra_options(cls, values: list[str]) -> list[str]:
|
|
198
|
+
for value in values:
|
|
199
|
+
_reject_control_characters(value, "extra option")
|
|
200
|
+
return values
|
|
201
|
+
|
|
202
|
+
@field_validator("filter_complex")
|
|
203
|
+
@classmethod
|
|
204
|
+
def _validate_filter_complex(cls, value: str | None) -> str | None:
|
|
205
|
+
if value is not None:
|
|
206
|
+
_reject_nul(value, "filter_complex")
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class PandocTask(TaskBase):
|
|
211
|
+
"""Canonical, local representation of one Pandoc conversion."""
|
|
212
|
+
|
|
213
|
+
description: Description = "New pandoc task"
|
|
214
|
+
|
|
215
|
+
from_format: FormatName | None = None
|
|
216
|
+
to_format: FormatName | None = None
|
|
217
|
+
standalone: bool = False
|
|
218
|
+
toc: bool = False
|
|
219
|
+
toc_depth: int | None = Field(default=None, ge=1, le=6)
|
|
220
|
+
number_sections: bool = False
|
|
221
|
+
shift_heading_level_by: int | None = Field(default=None, ge=-10, le=10)
|
|
222
|
+
metadata: list[MetadataEntry] = Field(default_factory=list)
|
|
223
|
+
template: PathValue | None = None
|
|
224
|
+
reference_doc: PathValue | None = None
|
|
225
|
+
pdf_engine: str | None = None
|
|
226
|
+
highlight_style: str | None = None
|
|
227
|
+
embed_resources: bool = False
|
|
228
|
+
citeproc: bool = False
|
|
229
|
+
bibliography: list[PathValue] = Field(default_factory=list)
|
|
230
|
+
csl: PathValue | None = None
|
|
231
|
+
resource_path: list[PathValue] = Field(default_factory=list)
|
|
232
|
+
extra_options: list[ExtraOption] = Field(default_factory=list)
|
|
233
|
+
|
|
234
|
+
@field_validator("metadata", "bibliography", "resource_path", "extra_options")
|
|
235
|
+
@classmethod
|
|
236
|
+
def _validate_string_lists(cls, values: list[str]) -> list[str]:
|
|
237
|
+
for value in values:
|
|
238
|
+
_reject_control_characters(value, "Pandoc list value")
|
|
239
|
+
return values
|
|
240
|
+
|
|
241
|
+
@field_validator("template", "reference_doc", "csl")
|
|
242
|
+
@classmethod
|
|
243
|
+
def _validate_path_values(cls, value: str | None) -> str | None:
|
|
244
|
+
if value is not None:
|
|
245
|
+
_reject_control_characters(value, "Pandoc path")
|
|
246
|
+
return value
|
|
247
|
+
|
|
248
|
+
@field_validator("pdf_engine")
|
|
249
|
+
@classmethod
|
|
250
|
+
def _validate_pdf_engine(cls, value: str | None) -> str | None:
|
|
251
|
+
return _validate_choice(value, PDF_ENGINES, "pdf_engine")
|
|
252
|
+
|
|
253
|
+
@field_validator("highlight_style")
|
|
254
|
+
@classmethod
|
|
255
|
+
def _validate_highlight_style(cls, value: str | None) -> str | None:
|
|
256
|
+
return _validate_choice(value, HIGHLIGHT_STYLES, "highlight_style")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class FFmpegPatch(BaseModel):
|
|
260
|
+
"""Incremental changes to an FFmpeg task.
|
|
261
|
+
|
|
262
|
+
Only fields present in the JSON payload are applied. ``model_fields_set``
|
|
263
|
+
distinguishes "unchanged" from an explicit ``null``, which clears a value.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
model_config = ConfigDict(extra="forbid")
|
|
267
|
+
|
|
268
|
+
video_filters: list[FilterExpression] | None = None
|
|
269
|
+
audio_filters: list[FilterExpression] | None = None
|
|
270
|
+
video_codec: CodecName | None = None
|
|
271
|
+
audio_codec: CodecName | None = None
|
|
272
|
+
video_bitrate: Bitrate | None = None
|
|
273
|
+
audio_bitrate: Bitrate | None = None
|
|
274
|
+
crf: int | None = Field(default=None, ge=0, le=63)
|
|
275
|
+
preset: Preset | None = None
|
|
276
|
+
extra_options: list[ExtraOption] | None = None
|
|
277
|
+
filter_complex: FilterExpression | None = None
|
|
278
|
+
maps: list[StreamMap] | None = None
|
|
279
|
+
description: Description | None = None
|
|
280
|
+
|
|
281
|
+
@field_validator("video_filters", "audio_filters", "maps", "extra_options")
|
|
282
|
+
@classmethod
|
|
283
|
+
def _validate_lists(cls, values: list[str] | None) -> list[str] | None:
|
|
284
|
+
if values is None:
|
|
285
|
+
return values
|
|
286
|
+
for value in values:
|
|
287
|
+
if "\x00" in value or "\n" in value or "\r" in value:
|
|
288
|
+
raise ValueError("Task list values must not contain NUL or newlines")
|
|
289
|
+
return values
|
|
290
|
+
|
|
291
|
+
@field_validator("filter_complex", "description")
|
|
292
|
+
@classmethod
|
|
293
|
+
def _validate_text(cls, value: str | None) -> str | None:
|
|
294
|
+
if value is not None:
|
|
295
|
+
_reject_nul(value, "field")
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class PandocPatch(BaseModel):
|
|
300
|
+
"""Incremental changes to a Pandoc task.
|
|
301
|
+
|
|
302
|
+
``None`` clears a field; omitted fields are unchanged. Boolean fields use
|
|
303
|
+
``false`` to turn an option off, and an explicit ``null`` also turns it off.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
model_config = ConfigDict(extra="forbid")
|
|
307
|
+
|
|
308
|
+
from_format: FormatName | None = None
|
|
309
|
+
to_format: FormatName | None = None
|
|
310
|
+
standalone: bool | None = None
|
|
311
|
+
toc: bool | None = None
|
|
312
|
+
toc_depth: int | None = Field(default=None, ge=1, le=6)
|
|
313
|
+
number_sections: bool | None = None
|
|
314
|
+
shift_heading_level_by: int | None = Field(default=None, ge=-10, le=10)
|
|
315
|
+
metadata: list[MetadataEntry] | None = None
|
|
316
|
+
template: PathValue | None = None
|
|
317
|
+
reference_doc: PathValue | None = None
|
|
318
|
+
pdf_engine: str | None = None
|
|
319
|
+
highlight_style: str | None = None
|
|
320
|
+
embed_resources: bool | None = None
|
|
321
|
+
citeproc: bool | None = None
|
|
322
|
+
bibliography: list[PathValue] | None = None
|
|
323
|
+
csl: PathValue | None = None
|
|
324
|
+
resource_path: list[PathValue] | None = None
|
|
325
|
+
extra_options: list[ExtraOption] | None = None
|
|
326
|
+
description: Description | None = None
|
|
327
|
+
|
|
328
|
+
@field_validator("metadata", "bibliography", "resource_path", "extra_options")
|
|
329
|
+
@classmethod
|
|
330
|
+
def _validate_lists(cls, values: list[str] | None) -> list[str] | None:
|
|
331
|
+
if values is None:
|
|
332
|
+
return values
|
|
333
|
+
for value in values:
|
|
334
|
+
if "\x00" in value or "\n" in value or "\r" in value:
|
|
335
|
+
raise ValueError("Pandoc list values must not contain NUL or newlines")
|
|
336
|
+
return values
|
|
337
|
+
|
|
338
|
+
@field_validator("template", "reference_doc", "csl", "description")
|
|
339
|
+
@classmethod
|
|
340
|
+
def _validate_text(cls, value: str | None) -> str | None:
|
|
341
|
+
if value is not None:
|
|
342
|
+
_reject_nul(value, "field")
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
@field_validator("pdf_engine")
|
|
346
|
+
@classmethod
|
|
347
|
+
def _validate_pdf_engine(cls, value: str | None) -> str | None:
|
|
348
|
+
return _validate_choice(value, PDF_ENGINES, "pdf_engine")
|
|
349
|
+
|
|
350
|
+
@field_validator("highlight_style")
|
|
351
|
+
@classmethod
|
|
352
|
+
def _validate_highlight_style(cls, value: str | None) -> str | None:
|
|
353
|
+
return _validate_choice(value, HIGHLIGHT_STYLES, "highlight_style")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_TOOL_PATCH_FIELDS: tuple[ToolName, ...] = ("ffmpeg", "pandoc")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class ToolPatch(BaseModel):
|
|
360
|
+
"""A patch that targets exactly one tool.
|
|
361
|
+
|
|
362
|
+
The JSON shape is ``{"ffmpeg": { ... }}`` or ``{"pandoc": { ... }}``. An
|
|
363
|
+
empty object (``{}``) means "no task changes" and is only valid for the
|
|
364
|
+
clarify/noop intents. A tool key with an empty payload selects that tool
|
|
365
|
+
without changing any of its fields.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
model_config = ConfigDict(extra="forbid")
|
|
369
|
+
|
|
370
|
+
ffmpeg: FFmpegPatch | None = None
|
|
371
|
+
pandoc: PandocPatch | None = None
|
|
372
|
+
|
|
373
|
+
@model_validator(mode="after")
|
|
374
|
+
def _exactly_one_tool(self) -> ToolPatch:
|
|
375
|
+
present = [name for name in _TOOL_PATCH_FIELDS if getattr(self, name) is not None]
|
|
376
|
+
if len(present) > 1:
|
|
377
|
+
raise ValueError("a patch must target exactly one tool, not several")
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def tool(self) -> ToolName | None:
|
|
382
|
+
for name in _TOOL_PATCH_FIELDS:
|
|
383
|
+
if getattr(self, name) is not None:
|
|
384
|
+
return name
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def fields(self) -> FFmpegPatch | PandocPatch | None:
|
|
389
|
+
tool = self.tool
|
|
390
|
+
return None if tool is None else getattr(self, tool)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class LLMDecision(BaseModel):
|
|
394
|
+
"""The only thing the language model is allowed to return."""
|
|
395
|
+
|
|
396
|
+
model_config = ConfigDict(extra="forbid")
|
|
397
|
+
|
|
398
|
+
intent: Literal["apply", "clarify", "noop"]
|
|
399
|
+
patch: ToolPatch = Field(default_factory=ToolPatch)
|
|
400
|
+
explanation: Annotated[str, StringConstraints(min_length=1, max_length=4_000)]
|
|
401
|
+
question: Annotated[str, StringConstraints(min_length=1, max_length=2_000)] | None = None
|
|
402
|
+
|
|
403
|
+
@model_validator(mode="after")
|
|
404
|
+
def _validate_decision(self) -> LLMDecision:
|
|
405
|
+
if self.intent in {"clarify", "noop"} and self.patch.model_fields_set:
|
|
406
|
+
raise ValueError(f"intent '{self.intent}' must not include task changes")
|
|
407
|
+
if self.intent == "apply" and self.patch.tool is None:
|
|
408
|
+
raise ValueError("intent 'apply' requires a patch for exactly one tool")
|
|
409
|
+
if self.intent == "clarify" and self.question is None:
|
|
410
|
+
raise ValueError("intent 'clarify' requires a question")
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# Fields shown by the REPL and used for change summaries. Keeping the order
|
|
415
|
+
# here gives panels and diffs a stable presentation.
|
|
416
|
+
FFMPEG_DISPLAY_FIELDS: tuple[str, ...] = (
|
|
417
|
+
"video_filters",
|
|
418
|
+
"audio_filters",
|
|
419
|
+
"filter_complex",
|
|
420
|
+
"maps",
|
|
421
|
+
"video_codec",
|
|
422
|
+
"audio_codec",
|
|
423
|
+
"crf",
|
|
424
|
+
"preset",
|
|
425
|
+
"video_bitrate",
|
|
426
|
+
"audio_bitrate",
|
|
427
|
+
"extra_options",
|
|
428
|
+
"description",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
PANDOC_DISPLAY_FIELDS: tuple[str, ...] = (
|
|
432
|
+
"from_format",
|
|
433
|
+
"to_format",
|
|
434
|
+
"standalone",
|
|
435
|
+
"toc",
|
|
436
|
+
"toc_depth",
|
|
437
|
+
"number_sections",
|
|
438
|
+
"shift_heading_level_by",
|
|
439
|
+
"metadata",
|
|
440
|
+
"template",
|
|
441
|
+
"reference_doc",
|
|
442
|
+
"pdf_engine",
|
|
443
|
+
"highlight_style",
|
|
444
|
+
"embed_resources",
|
|
445
|
+
"citeproc",
|
|
446
|
+
"bibliography",
|
|
447
|
+
"csl",
|
|
448
|
+
"resource_path",
|
|
449
|
+
"extra_options",
|
|
450
|
+
"description",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Backward-compatible name for the FFmpeg field presentation order.
|
|
454
|
+
TASK_DISPLAY_FIELDS = FFMPEG_DISPLAY_FIELDS
|