moxtral 0.4.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.
- moxtral/__init__.py +10 -0
- moxtral/__main__.py +4 -0
- moxtral/cli/__init__.py +1 -0
- moxtral/cli/agent.py +33 -0
- moxtral/cli/common.py +202 -0
- moxtral/cli/config.py +142 -0
- moxtral/cli/main.py +55 -0
- moxtral/cli/ocr.py +242 -0
- moxtral/cli/runner.py +528 -0
- moxtral/cli/transcribe.py +210 -0
- moxtral/config.py +182 -0
- moxtral/console.py +123 -0
- moxtral/data/agent_guide.md +129 -0
- moxtral/dedupe.py +253 -0
- moxtral/errors.py +181 -0
- moxtral/formatters.py +299 -0
- moxtral/mistral_client.py +232 -0
- moxtral/models.py +285 -0
- moxtral/schema.py +137 -0
- moxtral/services/__init__.py +14 -0
- moxtral/services/ocr.py +41 -0
- moxtral/services/transcription.py +41 -0
- moxtral/sources.py +159 -0
- moxtral/storage.py +256 -0
- moxtral-0.4.0.dist-info/METADATA +503 -0
- moxtral-0.4.0.dist-info/RECORD +29 -0
- moxtral-0.4.0.dist-info/WHEEL +4 -0
- moxtral-0.4.0.dist-info/entry_points.txt +2 -0
- moxtral-0.4.0.dist-info/licenses/LICENSE +21 -0
moxtral/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("moxtral")
|
|
7
|
+
except PackageNotFoundError: # e.g. vendored source tree without installation
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
moxtral/__main__.py
ADDED
moxtral/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface for moxtral."""
|
moxtral/cli/agent.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from moxtral.formatters import serialize_json
|
|
9
|
+
from moxtral.schema import record_schema
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from moxtral.cli.main import AppContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option(
|
|
17
|
+
"--schema",
|
|
18
|
+
"show_schema",
|
|
19
|
+
is_flag=True,
|
|
20
|
+
help="Print the JSON Schema describing --json output records.",
|
|
21
|
+
)
|
|
22
|
+
@click.pass_obj
|
|
23
|
+
def agent(context: AppContext, show_schema: bool) -> None:
|
|
24
|
+
"""Print agent-oriented usage documentation for this CLI."""
|
|
25
|
+
if show_schema:
|
|
26
|
+
context.consoles.write_stdout(serialize_json(record_schema()))
|
|
27
|
+
return
|
|
28
|
+
guide = (
|
|
29
|
+
resources.files("moxtral")
|
|
30
|
+
.joinpath("data/agent_guide.md")
|
|
31
|
+
.read_text(encoding="utf-8")
|
|
32
|
+
)
|
|
33
|
+
context.consoles.write_stdout(guide)
|
moxtral/cli/common.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol, cast
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from moxtral.config import ConfigStore
|
|
12
|
+
from moxtral.console import sanitize_terminal_text
|
|
13
|
+
from moxtral.errors import (
|
|
14
|
+
ConfigError,
|
|
15
|
+
format_debug_exception,
|
|
16
|
+
redact,
|
|
17
|
+
translate_exception,
|
|
18
|
+
)
|
|
19
|
+
from moxtral.models import ApiResult, InputSource, JSONMapping, JSONValue
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def positive_days(
|
|
23
|
+
context: click.Context,
|
|
24
|
+
parameter: click.Parameter,
|
|
25
|
+
value: float,
|
|
26
|
+
) -> float:
|
|
27
|
+
"""Validate a look-back window expressed as a positive, finite number of days."""
|
|
28
|
+
if not math.isfinite(value) or value <= 0:
|
|
29
|
+
raise click.BadParameter(
|
|
30
|
+
"must be a positive number of days",
|
|
31
|
+
ctx=context,
|
|
32
|
+
param=parameter,
|
|
33
|
+
)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def nonnegative_integer(
|
|
38
|
+
context: click.Context,
|
|
39
|
+
parameter: click.Parameter,
|
|
40
|
+
value: int | None,
|
|
41
|
+
) -> int | None:
|
|
42
|
+
"""Validate an optional nonnegative integer click option."""
|
|
43
|
+
if value is not None and value < 0:
|
|
44
|
+
raise click.BadParameter(
|
|
45
|
+
"must be a nonnegative integer",
|
|
46
|
+
ctx=context,
|
|
47
|
+
param=parameter,
|
|
48
|
+
)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandConsoles(Protocol):
|
|
53
|
+
def write_stdout(self, payload: str) -> None: ...
|
|
54
|
+
|
|
55
|
+
def write_stderr(self, payload: str) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommandContext(Protocol):
|
|
59
|
+
@property
|
|
60
|
+
def config_path(self) -> Path: ...
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def debug(self) -> bool: ...
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def consoles(self) -> CommandConsoles: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def safe_terminal_text(text: str, secrets: tuple[str, ...]) -> str:
|
|
70
|
+
"""Sanitize terminal controls and redact every resulting secret variant."""
|
|
71
|
+
safe_text = sanitize_terminal_text(text)
|
|
72
|
+
secret_variants: list[str] = []
|
|
73
|
+
for secret in secrets:
|
|
74
|
+
if not secret.strip():
|
|
75
|
+
continue
|
|
76
|
+
secret_variants.append(secret)
|
|
77
|
+
secret_variants.append(sanitize_terminal_text(secret))
|
|
78
|
+
return redact(safe_text, secret_variants)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def candidate_secrets(context: CommandContext) -> tuple[str, ...]:
|
|
82
|
+
"""Collect potential keys without allowing config errors to mask input errors."""
|
|
83
|
+
secrets: list[str] = []
|
|
84
|
+
environment_key = os.environ.get("MISTRAL_API_KEY")
|
|
85
|
+
if environment_key is not None and environment_key.strip():
|
|
86
|
+
secrets.append(environment_key)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
configured_key = ConfigStore(context.config_path, environ={}).load().api_key
|
|
90
|
+
except ConfigError:
|
|
91
|
+
configured_key = None
|
|
92
|
+
if configured_key is not None:
|
|
93
|
+
secrets.append(configured_key)
|
|
94
|
+
return tuple(dict.fromkeys(secrets))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extend_secrets(secrets: tuple[str, ...], secret: str) -> tuple[str, ...]:
|
|
98
|
+
"""Append a newly resolved secret while preserving order and uniqueness."""
|
|
99
|
+
if secret in secrets:
|
|
100
|
+
return secrets
|
|
101
|
+
return (*secrets, secret)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact_json_value(
|
|
105
|
+
value: JSONValue,
|
|
106
|
+
secrets: tuple[str, ...],
|
|
107
|
+
) -> JSONValue:
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
return redact(value, secrets)
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
return [_redact_json_value(item, secrets) for item in value]
|
|
112
|
+
if isinstance(value, Mapping):
|
|
113
|
+
return _redact_json_mapping(cast(JSONMapping, value), secrets)
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redact_json_mapping(
|
|
118
|
+
mapping: JSONMapping,
|
|
119
|
+
secrets: tuple[str, ...],
|
|
120
|
+
) -> dict[str, JSONValue]:
|
|
121
|
+
redacted_mapping: dict[str, JSONValue] = {}
|
|
122
|
+
for key, value in mapping.items():
|
|
123
|
+
safe_key = redact(key, secrets)
|
|
124
|
+
unique_key = safe_key
|
|
125
|
+
suffix = 1
|
|
126
|
+
while unique_key in redacted_mapping:
|
|
127
|
+
unique_key = f"{safe_key}#{suffix}"
|
|
128
|
+
suffix += 1
|
|
129
|
+
redacted_mapping[unique_key] = _redact_json_value(value, secrets)
|
|
130
|
+
return redacted_mapping
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _redact_source(
|
|
134
|
+
source: InputSource,
|
|
135
|
+
secrets: tuple[str, ...],
|
|
136
|
+
) -> InputSource:
|
|
137
|
+
safe_path = None if source.path is None else Path(redact(str(source.path), secrets))
|
|
138
|
+
return InputSource(
|
|
139
|
+
kind=source.kind,
|
|
140
|
+
value=redact(source.value, secrets),
|
|
141
|
+
filename=redact(source.filename, secrets),
|
|
142
|
+
path=safe_path,
|
|
143
|
+
ocr_kind=source.ocr_kind,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def redact_result(
|
|
148
|
+
result: ApiResult,
|
|
149
|
+
secrets: tuple[str, ...],
|
|
150
|
+
) -> ApiResult:
|
|
151
|
+
"""Return an API result with secrets recursively removed."""
|
|
152
|
+
return ApiResult(
|
|
153
|
+
operation=result.operation,
|
|
154
|
+
source=_redact_source(result.source, secrets),
|
|
155
|
+
request_metadata=_redact_json_mapping(result.request_metadata, secrets),
|
|
156
|
+
response=_redact_json_mapping(result.response, secrets),
|
|
157
|
+
created_at=result.created_at,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def report_error(
|
|
162
|
+
context: CommandContext,
|
|
163
|
+
error: Exception,
|
|
164
|
+
*,
|
|
165
|
+
secrets: tuple[str, ...],
|
|
166
|
+
setup_debug_context: str,
|
|
167
|
+
source_debug_prefix: str,
|
|
168
|
+
source: str | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Translate and safely report a setup or per-source command error."""
|
|
171
|
+
translated = translate_exception(error)
|
|
172
|
+
line = (
|
|
173
|
+
f"Setup error: {translated}\n"
|
|
174
|
+
if source is None
|
|
175
|
+
else f"{source}: {translated}\n"
|
|
176
|
+
)
|
|
177
|
+
debug_context = (
|
|
178
|
+
setup_debug_context if source is None else f"{source_debug_prefix}: {source}"
|
|
179
|
+
)
|
|
180
|
+
context.consoles.write_stderr(safe_terminal_text(line, secrets))
|
|
181
|
+
if context.debug:
|
|
182
|
+
write_debug_exception(
|
|
183
|
+
context,
|
|
184
|
+
error,
|
|
185
|
+
secrets=secrets,
|
|
186
|
+
debug_context=debug_context,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_debug_exception(
|
|
191
|
+
context: CommandContext,
|
|
192
|
+
error: Exception,
|
|
193
|
+
*,
|
|
194
|
+
secrets: tuple[str, ...],
|
|
195
|
+
debug_context: str,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Write traceback diagnostics through the shared safe terminal boundary."""
|
|
198
|
+
formatted = format_debug_exception(
|
|
199
|
+
error,
|
|
200
|
+
context=debug_context,
|
|
201
|
+
)
|
|
202
|
+
context.consoles.write_stderr(safe_terminal_text(formatted, secrets))
|
moxtral/cli/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING, NoReturn, cast
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import tomli_w
|
|
8
|
+
|
|
9
|
+
from moxtral.cli.common import (
|
|
10
|
+
candidate_secrets,
|
|
11
|
+
extend_secrets,
|
|
12
|
+
safe_terminal_text,
|
|
13
|
+
write_debug_exception,
|
|
14
|
+
)
|
|
15
|
+
from moxtral.config import ConfigStore
|
|
16
|
+
from moxtral.errors import ConfigError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from moxtral.cli.main import AppContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def config() -> None:
|
|
24
|
+
"""Manage CLI configuration."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _raise_config_error(
|
|
28
|
+
context: AppContext,
|
|
29
|
+
error: ConfigError,
|
|
30
|
+
*,
|
|
31
|
+
secrets: tuple[str, ...],
|
|
32
|
+
debug_context: str,
|
|
33
|
+
) -> NoReturn:
|
|
34
|
+
if context.debug:
|
|
35
|
+
write_debug_exception(
|
|
36
|
+
context,
|
|
37
|
+
error,
|
|
38
|
+
secrets=secrets,
|
|
39
|
+
debug_context=debug_context,
|
|
40
|
+
)
|
|
41
|
+
raise click.ClickException(safe_terminal_text(str(error), secrets)) from error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@config.command(
|
|
45
|
+
"set",
|
|
46
|
+
context_settings={
|
|
47
|
+
"allow_extra_args": True,
|
|
48
|
+
"ignore_unknown_options": True,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
@click.argument("name", type=click.Choice(["api-key"]))
|
|
52
|
+
@click.option(
|
|
53
|
+
"--stdin",
|
|
54
|
+
"read_stdin",
|
|
55
|
+
is_flag=True,
|
|
56
|
+
help="Read the value from standard input.",
|
|
57
|
+
)
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def set_value(context: click.Context, name: str, read_stdin: bool) -> None:
|
|
60
|
+
"""Set a configuration value."""
|
|
61
|
+
if context.args:
|
|
62
|
+
raise click.UsageError(
|
|
63
|
+
"API keys must be provided through the prompt or --stdin.",
|
|
64
|
+
ctx=context,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if read_stdin:
|
|
68
|
+
try:
|
|
69
|
+
value = sys.stdin.read().rstrip("\r\n")
|
|
70
|
+
except UnicodeError as error:
|
|
71
|
+
raise click.ClickException(
|
|
72
|
+
"Could not read API key from standard input."
|
|
73
|
+
) from error
|
|
74
|
+
if not value:
|
|
75
|
+
raise click.ClickException("Standard input must not be empty.")
|
|
76
|
+
if "\r" in value or "\n" in value:
|
|
77
|
+
raise click.ClickException("Standard input must contain a single line.")
|
|
78
|
+
else:
|
|
79
|
+
value = click.prompt(
|
|
80
|
+
"API key",
|
|
81
|
+
hide_input=True,
|
|
82
|
+
confirmation_prompt=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
app_context = cast("AppContext", context.obj)
|
|
86
|
+
try:
|
|
87
|
+
ConfigStore(app_context.config_path).set(name, value)
|
|
88
|
+
except ConfigError as error:
|
|
89
|
+
secrets = extend_secrets(candidate_secrets(app_context), value)
|
|
90
|
+
_raise_config_error(
|
|
91
|
+
app_context,
|
|
92
|
+
error,
|
|
93
|
+
secrets=secrets,
|
|
94
|
+
debug_context=f"setting configuration {name}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
click.echo("Configuration updated.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@config.command("show")
|
|
101
|
+
@click.pass_obj
|
|
102
|
+
def show(context: AppContext) -> None:
|
|
103
|
+
"""Show configuration with secrets redacted."""
|
|
104
|
+
try:
|
|
105
|
+
values = ConfigStore(context.config_path).redacted()
|
|
106
|
+
except ConfigError as error:
|
|
107
|
+
_raise_config_error(
|
|
108
|
+
context,
|
|
109
|
+
error,
|
|
110
|
+
secrets=candidate_secrets(context),
|
|
111
|
+
debug_context="showing configuration",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
click.echo(tomli_w.dumps(values), nl=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@config.command("unset")
|
|
118
|
+
@click.argument("name", type=click.Choice(["api-key"]))
|
|
119
|
+
@click.pass_obj
|
|
120
|
+
def unset(context: AppContext, name: str) -> None:
|
|
121
|
+
"""Remove a configuration value."""
|
|
122
|
+
try:
|
|
123
|
+
removed = ConfigStore(context.config_path).unset(name)
|
|
124
|
+
except ConfigError as error:
|
|
125
|
+
_raise_config_error(
|
|
126
|
+
context,
|
|
127
|
+
error,
|
|
128
|
+
secrets=candidate_secrets(context),
|
|
129
|
+
debug_context=f"unsetting configuration {name}",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if removed:
|
|
133
|
+
click.echo(f"{name} removed.")
|
|
134
|
+
else:
|
|
135
|
+
click.echo(f"{name} already absent.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@config.command("path")
|
|
139
|
+
@click.pass_obj
|
|
140
|
+
def show_path(context: AppContext) -> None:
|
|
141
|
+
"""Print the effective configuration path."""
|
|
142
|
+
click.echo(context.config_path)
|
moxtral/cli/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from moxtral import __version__
|
|
9
|
+
from moxtral.cli.agent import agent
|
|
10
|
+
from moxtral.cli.config import config
|
|
11
|
+
from moxtral.cli.ocr import ocr
|
|
12
|
+
from moxtral.cli.transcribe import transcribe
|
|
13
|
+
from moxtral.console import ConsoleBundle, create_console_bundle
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_PATH = Path("~/.moxtral/config.toml").expanduser()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class AppContext:
|
|
20
|
+
config_path: Path
|
|
21
|
+
debug: bool
|
|
22
|
+
consoles: ConsoleBundle
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group(
|
|
26
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
27
|
+
epilog=(
|
|
28
|
+
"Run 'moxtral agent' for agent-oriented usage docs and "
|
|
29
|
+
"'moxtral agent --schema' for the JSON output schema."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
@click.version_option(version=__version__)
|
|
33
|
+
@click.option("--debug", is_flag=True, help="Show detailed error information.")
|
|
34
|
+
@click.option(
|
|
35
|
+
"--config",
|
|
36
|
+
"config_path",
|
|
37
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
38
|
+
default=DEFAULT_CONFIG_PATH,
|
|
39
|
+
show_default=True,
|
|
40
|
+
help="Path to the configuration file.",
|
|
41
|
+
)
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def cli(ctx: click.Context, debug: bool, config_path: Path) -> None:
|
|
44
|
+
"""Work with Mistral OCR and audio transcription."""
|
|
45
|
+
ctx.obj = AppContext(
|
|
46
|
+
config_path=config_path,
|
|
47
|
+
debug=debug,
|
|
48
|
+
consoles=create_console_bundle(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
cli.add_command(agent)
|
|
53
|
+
cli.add_command(config)
|
|
54
|
+
cli.add_command(ocr)
|
|
55
|
+
cli.add_command(transcribe)
|
moxtral/cli/ocr.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from moxtral.cli.common import nonnegative_integer, positive_days
|
|
9
|
+
from moxtral.cli.runner import BatchPlan, DedupeOptions, OutputOptions, run_batch
|
|
10
|
+
from moxtral.formatters import format_ocr_markdown
|
|
11
|
+
from moxtral.mistral_client import MistralGateway
|
|
12
|
+
from moxtral.models import (
|
|
13
|
+
DEFAULT_RETRIES,
|
|
14
|
+
Confidence,
|
|
15
|
+
OcrRequest,
|
|
16
|
+
Operation,
|
|
17
|
+
OutputFormat,
|
|
18
|
+
TableFormat,
|
|
19
|
+
build_ocr_request,
|
|
20
|
+
ocr_request_metadata,
|
|
21
|
+
)
|
|
22
|
+
from moxtral.services.ocr import OcrGateway, OcrService
|
|
23
|
+
from moxtral.sources import resolve_source
|
|
24
|
+
from moxtral.storage import ResultStore
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from moxtral.cli.main import AppContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_gateway(api_key: str) -> OcrGateway:
|
|
31
|
+
"""Create the production OCR gateway."""
|
|
32
|
+
return MistralGateway(api_key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_result_store() -> ResultStore:
|
|
36
|
+
"""Create the production result store."""
|
|
37
|
+
return ResultStore()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.command()
|
|
41
|
+
@click.argument("sources", metavar="SOURCE...", nargs=-1, required=True)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--model",
|
|
44
|
+
default="mistral-ocr-latest",
|
|
45
|
+
show_default=True,
|
|
46
|
+
help="OCR model.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--pages",
|
|
50
|
+
help="Page numbers or ranges in API syntax, such as 0,2-4.",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--table-format",
|
|
54
|
+
type=click.Choice(["inline", "markdown", "html"]),
|
|
55
|
+
default="inline",
|
|
56
|
+
show_default=True,
|
|
57
|
+
help="How tables are represented in the OCR response.",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--extract-header",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
help="Extract page headers.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--extract-footer",
|
|
66
|
+
is_flag=True,
|
|
67
|
+
help="Extract page footers.",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--include-images",
|
|
71
|
+
is_flag=True,
|
|
72
|
+
help="Include extracted image data in the response.",
|
|
73
|
+
)
|
|
74
|
+
@click.option(
|
|
75
|
+
"--image-limit",
|
|
76
|
+
type=int,
|
|
77
|
+
callback=nonnegative_integer,
|
|
78
|
+
help="Maximum number of images to extract (requires --include-images).",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--image-min-size",
|
|
82
|
+
type=int,
|
|
83
|
+
callback=nonnegative_integer,
|
|
84
|
+
help="Minimum extracted image size (requires --include-images).",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--include-blocks",
|
|
88
|
+
is_flag=True,
|
|
89
|
+
help="Include structured OCR blocks.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--confidence",
|
|
93
|
+
type=click.Choice(["none", "page", "word"]),
|
|
94
|
+
default="none",
|
|
95
|
+
show_default=True,
|
|
96
|
+
help="Confidence score granularity.",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--output-dir",
|
|
100
|
+
type=click.Path(path_type=Path, file_okay=False),
|
|
101
|
+
help="Directory in which to save result files.",
|
|
102
|
+
)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--format",
|
|
105
|
+
"output_format",
|
|
106
|
+
type=click.Choice(["md", "json", "both"]),
|
|
107
|
+
default="both",
|
|
108
|
+
show_default=True,
|
|
109
|
+
help="Result file format to save.",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--timeout",
|
|
113
|
+
type=float,
|
|
114
|
+
default=300.0,
|
|
115
|
+
show_default=True,
|
|
116
|
+
help="Request timeout in seconds.",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--retries",
|
|
120
|
+
type=int,
|
|
121
|
+
default=DEFAULT_RETRIES,
|
|
122
|
+
show_default=True,
|
|
123
|
+
callback=nonnegative_integer,
|
|
124
|
+
help=(
|
|
125
|
+
"Retry attempts for rate-limited, server-error, and connection "
|
|
126
|
+
"failures (0 disables)."
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--stdout",
|
|
131
|
+
"write_stdout",
|
|
132
|
+
is_flag=True,
|
|
133
|
+
help="Also write OCR Markdown to standard output.",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"--json",
|
|
137
|
+
"write_json",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
help="Write NDJSON result records to standard output (one per source).",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--quiet",
|
|
143
|
+
is_flag=True,
|
|
144
|
+
help="Suppress progress and summary output.",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--no-save",
|
|
148
|
+
is_flag=True,
|
|
149
|
+
help="Do not save result files (requires --json or --stdout).",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--dry-run",
|
|
153
|
+
is_flag=True,
|
|
154
|
+
help="Validate sources and options without calling the API.",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--force",
|
|
158
|
+
is_flag=True,
|
|
159
|
+
help="Process the source even if an identical recent result exists.",
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--dedupe-window",
|
|
163
|
+
type=float,
|
|
164
|
+
default=30.0,
|
|
165
|
+
show_default=True,
|
|
166
|
+
metavar="DAYS",
|
|
167
|
+
callback=positive_days,
|
|
168
|
+
help="Look-back window in days for skipping identical, already-saved results.",
|
|
169
|
+
)
|
|
170
|
+
@click.pass_obj
|
|
171
|
+
def ocr(
|
|
172
|
+
context: AppContext,
|
|
173
|
+
sources: tuple[str, ...],
|
|
174
|
+
model: str,
|
|
175
|
+
pages: str | None,
|
|
176
|
+
table_format: str,
|
|
177
|
+
extract_header: bool,
|
|
178
|
+
extract_footer: bool,
|
|
179
|
+
include_images: bool,
|
|
180
|
+
image_limit: int | None,
|
|
181
|
+
image_min_size: int | None,
|
|
182
|
+
include_blocks: bool,
|
|
183
|
+
confidence: str,
|
|
184
|
+
output_dir: Path | None,
|
|
185
|
+
output_format: str,
|
|
186
|
+
timeout: float,
|
|
187
|
+
retries: int,
|
|
188
|
+
write_stdout: bool,
|
|
189
|
+
write_json: bool,
|
|
190
|
+
quiet: bool,
|
|
191
|
+
no_save: bool,
|
|
192
|
+
dry_run: bool,
|
|
193
|
+
force: bool,
|
|
194
|
+
dedupe_window: float,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Extract readable text from local documents, images, or HTTP(S) URLs."""
|
|
197
|
+
selected_table_format = (
|
|
198
|
+
None if table_format == "inline" else cast(TableFormat, table_format)
|
|
199
|
+
)
|
|
200
|
+
selected_confidence = None if confidence == "none" else cast(Confidence, confidence)
|
|
201
|
+
|
|
202
|
+
def build_request(source_value: str) -> OcrRequest:
|
|
203
|
+
return build_ocr_request(
|
|
204
|
+
source=resolve_source(source_value, Operation.OCR),
|
|
205
|
+
model=model,
|
|
206
|
+
pages=pages,
|
|
207
|
+
table_format=selected_table_format,
|
|
208
|
+
extract_header=extract_header,
|
|
209
|
+
extract_footer=extract_footer,
|
|
210
|
+
include_images=include_images,
|
|
211
|
+
image_limit=image_limit,
|
|
212
|
+
image_min_size=image_min_size,
|
|
213
|
+
include_blocks=include_blocks,
|
|
214
|
+
confidence=selected_confidence,
|
|
215
|
+
timeout_seconds=timeout,
|
|
216
|
+
retries=retries,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
run_batch(
|
|
220
|
+
context,
|
|
221
|
+
sources,
|
|
222
|
+
BatchPlan(
|
|
223
|
+
setup_debug_context="setting up OCR command",
|
|
224
|
+
source_debug_prefix="OCR source",
|
|
225
|
+
operation=Operation.OCR,
|
|
226
|
+
build_request=build_request,
|
|
227
|
+
request_metadata=ocr_request_metadata,
|
|
228
|
+
create_service=lambda api_key: OcrService(create_gateway(api_key)),
|
|
229
|
+
create_store=lambda: create_result_store(),
|
|
230
|
+
format_markdown=format_ocr_markdown,
|
|
231
|
+
),
|
|
232
|
+
OutputOptions(
|
|
233
|
+
output_format=OutputFormat(output_format),
|
|
234
|
+
output_dir=output_dir,
|
|
235
|
+
write_markdown_stdout=write_stdout,
|
|
236
|
+
write_json_stdout=write_json,
|
|
237
|
+
quiet=quiet,
|
|
238
|
+
no_save=no_save,
|
|
239
|
+
dry_run=dry_run,
|
|
240
|
+
),
|
|
241
|
+
DedupeOptions(force=force, window_days=dedupe_window),
|
|
242
|
+
)
|