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/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