wenyi-cli 1.4.2__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.
- wenyi_cli/__init__.py +9 -0
- wenyi_cli/__main__.py +3 -0
- wenyi_cli/cli.py +45 -0
- wenyi_cli/commands/__init__.py +1 -0
- wenyi_cli/commands/bootstrap.py +108 -0
- wenyi_cli/commands/context.py +50 -0
- wenyi_cli/commands/glossary.py +104 -0
- wenyi_cli/commands/inspection.py +179 -0
- wenyi_cli/commands/machine_output.py +102 -0
- wenyi_cli/commands/presentation.py +139 -0
- wenyi_cli/commands/progress.py +92 -0
- wenyi_cli/commands/run_report.py +103 -0
- wenyi_cli/commands/validation.py +97 -0
- wenyi_cli/commands/workflows.py +393 -0
- wenyi_cli/main.py +5 -0
- wenyi_cli/model_commands.py +165 -0
- wenyi_cli-1.4.2.dist-info/METADATA +38 -0
- wenyi_cli-1.4.2.dist-info/RECORD +20 -0
- wenyi_cli-1.4.2.dist-info/WHEEL +4 -0
- wenyi_cli-1.4.2.dist-info/entry_points.txt +2 -0
wenyi_cli/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Wenyi command-line package for local workflows.
|
|
2
|
+
|
|
3
|
+
``wenyi_cli.cli`` assembles commands that use the shared core and file storage.
|
|
4
|
+
The package exposes the ``wenyi`` console script and a Python module entry point.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .main import app, main
|
|
8
|
+
|
|
9
|
+
__all__ = ["app", "main"]
|
wenyi_cli/__main__.py
ADDED
wenyi_cli/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Construct the command-line application and expose the installed entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from wenyi_core.observability import configure_logging
|
|
8
|
+
|
|
9
|
+
from .commands.bootstrap import configure_windows_console, initializing_group, register_bootstrap
|
|
10
|
+
from .commands.context import current_context
|
|
11
|
+
from .commands.glossary import register_glossary_commands
|
|
12
|
+
from .commands.inspection import register_inspection_commands
|
|
13
|
+
from .commands.workflows import register_workflows_commands
|
|
14
|
+
from .model_commands import register_model_commands
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_app(*, console: Console | None = None) -> typer.Typer:
|
|
18
|
+
"""Build an independent application with fresh context on every invocation."""
|
|
19
|
+
configure_windows_console()
|
|
20
|
+
configure_logging(service="wenyi-cli")
|
|
21
|
+
console = console or Console()
|
|
22
|
+
application = typer.Typer(
|
|
23
|
+
cls=initializing_group(console),
|
|
24
|
+
add_completion=False,
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
help="Multilingual translation workflows for long-form fiction.",
|
|
27
|
+
)
|
|
28
|
+
register_bootstrap(application)
|
|
29
|
+
register_model_commands(application, lambda: current_context().load_config(), console)
|
|
30
|
+
register_workflows_commands(application, current_context)
|
|
31
|
+
register_inspection_commands(application, current_context)
|
|
32
|
+
register_glossary_commands(application, current_context)
|
|
33
|
+
return application
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = create_app()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
"""Start the Typer command-line application."""
|
|
41
|
+
app()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command registration and terminal presentation helpers."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Early configuration creation, platform console setup and root CLI options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from importlib.metadata import version as package_version
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from typer.core import TyperGroup
|
|
14
|
+
from wenyi_core.config import Config
|
|
15
|
+
|
|
16
|
+
from .context import CommandContext, current_context
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_windows_console(
|
|
20
|
+
streams: tuple[object, ...] | None = None,
|
|
21
|
+
*,
|
|
22
|
+
is_windows: bool | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Enable Unicode output on Windows, including PyInstaller one-file executables."""
|
|
25
|
+
if is_windows is None:
|
|
26
|
+
is_windows = os.name == "nt"
|
|
27
|
+
if not is_windows:
|
|
28
|
+
return
|
|
29
|
+
for stream in streams or (sys.stdout, sys.stderr):
|
|
30
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
31
|
+
if callable(reconfigure):
|
|
32
|
+
try:
|
|
33
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path_from_args(args: Sequence[str]) -> str:
|
|
39
|
+
"""Locate the config before Click parses arguments, including help and early exits."""
|
|
40
|
+
for index, arg in enumerate(args):
|
|
41
|
+
if arg in {"--config", "-c"}:
|
|
42
|
+
if index + 1 < len(args):
|
|
43
|
+
return args[index + 1]
|
|
44
|
+
break
|
|
45
|
+
if arg.startswith("--config="):
|
|
46
|
+
return arg.partition("=")[2]
|
|
47
|
+
if arg.startswith("-c") and len(arg) > 2:
|
|
48
|
+
return arg[2:]
|
|
49
|
+
return "config.yaml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def initializing_group(console: Console) -> type[TyperGroup]:
|
|
53
|
+
"""Bind application presentation while allocating invocation data anew in main."""
|
|
54
|
+
|
|
55
|
+
class ConfigInitializingGroup(TyperGroup):
|
|
56
|
+
"""Check the default configuration before Click dispatches or exits early."""
|
|
57
|
+
|
|
58
|
+
def main(
|
|
59
|
+
self,
|
|
60
|
+
args: Sequence[str] | None = None,
|
|
61
|
+
*main_args: Any,
|
|
62
|
+
**main_kwargs: Any,
|
|
63
|
+
) -> Any:
|
|
64
|
+
"""Locate and create a missing default config before Click parses the command."""
|
|
65
|
+
cli_args = list(args) if args is not None else sys.argv[1:]
|
|
66
|
+
config_path = config_path_from_args(cli_args)
|
|
67
|
+
main_kwargs["obj"] = CommandContext(
|
|
68
|
+
config_path, any(arg in {"--help", "-h"} for arg in cli_args), console
|
|
69
|
+
)
|
|
70
|
+
Config.create_default_file(config_path)
|
|
71
|
+
from wenyi_core.llm.limits import RequestStopped
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return super().main(*main_args, args=args, **main_kwargs)
|
|
75
|
+
except RequestStopped as error:
|
|
76
|
+
typer.echo(f"Stopped: {error}", err=True)
|
|
77
|
+
raise SystemExit(1) from None
|
|
78
|
+
|
|
79
|
+
return ConfigInitializingGroup
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_bootstrap(app: typer.Typer) -> None:
|
|
83
|
+
def _version_callback(value: bool) -> None:
|
|
84
|
+
"""Print the installed package version derived from Git tags and exit."""
|
|
85
|
+
if value:
|
|
86
|
+
current_context().console.print(package_version("wenyi-cli"))
|
|
87
|
+
raise typer.Exit()
|
|
88
|
+
|
|
89
|
+
@app.callback()
|
|
90
|
+
def _root(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
config: str = typer.Option(
|
|
93
|
+
"config.yaml",
|
|
94
|
+
"--config",
|
|
95
|
+
"-c",
|
|
96
|
+
help="Configuration path; created automatically if missing",
|
|
97
|
+
),
|
|
98
|
+
version: bool = typer.Option(
|
|
99
|
+
False,
|
|
100
|
+
"--version",
|
|
101
|
+
callback=_version_callback,
|
|
102
|
+
is_eager=True,
|
|
103
|
+
help="Show the version and exit",
|
|
104
|
+
),
|
|
105
|
+
):
|
|
106
|
+
"""Record the config path; workflow commands validate credentials after their overrides."""
|
|
107
|
+
del version
|
|
108
|
+
current_context().config_path = config
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Configuration and preflight policy scoped to one Click invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from typer.main import get_current_context
|
|
12
|
+
from wenyi_core.config import Config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CommandContext:
|
|
17
|
+
config_path: str
|
|
18
|
+
skip_api_check: bool
|
|
19
|
+
console: Console
|
|
20
|
+
|
|
21
|
+
def load_config(self) -> Config:
|
|
22
|
+
"""Load the configuration selected for this invocation."""
|
|
23
|
+
try:
|
|
24
|
+
return Config.load(self.config_path)
|
|
25
|
+
except (OSError, ValueError, yaml.YAMLError) as error:
|
|
26
|
+
self.console.print(f"[red]Configuration error: {error}[/]")
|
|
27
|
+
raise typer.Exit(1) from None
|
|
28
|
+
|
|
29
|
+
def validate_api_configuration(self, config: Config, workflow: str) -> None:
|
|
30
|
+
"""Validate only the connections reachable after command-line overrides."""
|
|
31
|
+
from wenyi_core.llm.factory import build_client
|
|
32
|
+
from wenyi_core.llm.operations import configured_operations
|
|
33
|
+
|
|
34
|
+
if not self.skip_api_check:
|
|
35
|
+
try:
|
|
36
|
+
build_client(config).validate_credentials(configured_operations(config, workflow))
|
|
37
|
+
except (ValueError, RuntimeError) as error:
|
|
38
|
+
self.console.print(f"[red]Error: {error}[/]")
|
|
39
|
+
raise typer.Exit(1) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ContextAccessor = Callable[[], CommandContext]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def current_context() -> CommandContext:
|
|
46
|
+
"""Return the root invocation data, including during eager option callbacks."""
|
|
47
|
+
context = get_current_context().find_root().obj
|
|
48
|
+
if not isinstance(context, CommandContext):
|
|
49
|
+
raise RuntimeError("The CLI invocation has no CommandContext")
|
|
50
|
+
return context
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Register glossary commands with an invocation context accessor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from .context import ContextAccessor
|
|
9
|
+
from .validation import (
|
|
10
|
+
runstore_for_cli,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_glossary_commands(app: typer.Typer, context: ContextAccessor) -> None:
|
|
15
|
+
glossary_app = typer.Typer(
|
|
16
|
+
add_completion=False,
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
help="Inspect glossary entries, check conflicts and resolve translations.",
|
|
19
|
+
)
|
|
20
|
+
app.add_typer(glossary_app, name="glossary", rich_help_panel="Glossary")
|
|
21
|
+
|
|
22
|
+
@glossary_app.command("list")
|
|
23
|
+
def glossary_list(
|
|
24
|
+
input: str = typer.Argument(..., help="Source file with existing translation state"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List established glossary translations and their status."""
|
|
27
|
+
console = context().console
|
|
28
|
+
from wenyi_core.glossary.store import GlossaryStore
|
|
29
|
+
|
|
30
|
+
config = context().load_config()
|
|
31
|
+
store = runstore_for_cli(config, input, console=console)
|
|
32
|
+
if not store.exists():
|
|
33
|
+
console.print("[yellow]No progress found. Run prepare or translate first.[/]")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
g = GlossaryStore(store.glossary_path)
|
|
36
|
+
try:
|
|
37
|
+
table = Table("Source", "Translation", "Type", "Status")
|
|
38
|
+
# all_terms() preserves insertion order for prompt prefix caching.
|
|
39
|
+
# Sort only this display by type/source; preserve the shared data order.
|
|
40
|
+
for term in sorted(g.all_terms(), key=lambda t: (t.type, t.source)):
|
|
41
|
+
table.add_row(
|
|
42
|
+
term.source,
|
|
43
|
+
term.target,
|
|
44
|
+
f"{term.type}{'/' + term.gender if term.gender else ''}",
|
|
45
|
+
term.status,
|
|
46
|
+
)
|
|
47
|
+
console.print(table)
|
|
48
|
+
finally:
|
|
49
|
+
g.close()
|
|
50
|
+
|
|
51
|
+
@glossary_app.command("conflicts")
|
|
52
|
+
def glossary_conflicts(
|
|
53
|
+
input: str = typer.Argument(..., help="Source file with existing translation state"),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""List unresolved translation conflicts discovered during extraction."""
|
|
56
|
+
console = context().console
|
|
57
|
+
from wenyi_core.glossary.store import GlossaryStore
|
|
58
|
+
|
|
59
|
+
config = context().load_config()
|
|
60
|
+
store = runstore_for_cli(config, input, console=console)
|
|
61
|
+
if not store.exists():
|
|
62
|
+
console.print("[yellow]No progress found. Run translate or prepare first.[/]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
glossary = GlossaryStore(store.glossary_path)
|
|
65
|
+
try:
|
|
66
|
+
conflicts = glossary.open_conflicts()
|
|
67
|
+
if not conflicts:
|
|
68
|
+
console.print("No unresolved glossary conflicts.")
|
|
69
|
+
return
|
|
70
|
+
for conflict in conflicts:
|
|
71
|
+
console.print(
|
|
72
|
+
f" {conflict['source']}: existing “{conflict['existing_target']}” vs "
|
|
73
|
+
f"proposed “{conflict['proposed_target']}”"
|
|
74
|
+
f"(chapter {conflict['chapter']})"
|
|
75
|
+
)
|
|
76
|
+
finally:
|
|
77
|
+
glossary.close()
|
|
78
|
+
|
|
79
|
+
@glossary_app.command("resolve")
|
|
80
|
+
def glossary_resolve(
|
|
81
|
+
input: str = typer.Argument(..., help="Source file with existing translation state"),
|
|
82
|
+
source: str = typer.Argument(..., help="Source term to resolve"),
|
|
83
|
+
target: str = typer.Argument(
|
|
84
|
+
..., help="Target translation to use consistently from now on"
|
|
85
|
+
),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Resolve an existing term to a chosen translation and close its conflicts."""
|
|
88
|
+
console = context().console
|
|
89
|
+
from wenyi_core.glossary import resolver
|
|
90
|
+
from wenyi_core.glossary.store import GlossaryStore
|
|
91
|
+
|
|
92
|
+
config = context().load_config()
|
|
93
|
+
store = runstore_for_cli(config, input, console=console)
|
|
94
|
+
if not store.exists():
|
|
95
|
+
console.print("[yellow]No progress found. Run translate or prepare first.[/]")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
glossary = GlossaryStore(store.glossary_path)
|
|
98
|
+
try:
|
|
99
|
+
if not resolver.resolve(glossary, source, target):
|
|
100
|
+
console.print(f"[red]Term does not exist: {source}[/]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
console.print(f"Resolved {source} → {target}")
|
|
103
|
+
finally:
|
|
104
|
+
glossary.close()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Register inspection commands with an invocation context accessor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from wenyi_core.timing import load_timing
|
|
8
|
+
|
|
9
|
+
from wenyi_cli.commands import presentation
|
|
10
|
+
|
|
11
|
+
from .context import ContextAccessor
|
|
12
|
+
from .run_report import build_report
|
|
13
|
+
from .validation import (
|
|
14
|
+
require_input_file,
|
|
15
|
+
resolve_output_format,
|
|
16
|
+
runstore_for_cli,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_inspection_commands(app: typer.Typer, context: ContextAccessor) -> None:
|
|
21
|
+
@app.command(rich_help_panel="State and output")
|
|
22
|
+
def language_policy(
|
|
23
|
+
source: str | None = typer.Option(
|
|
24
|
+
None, "--source", help="Resolved source language for preview"
|
|
25
|
+
),
|
|
26
|
+
fmt: str | None = typer.Option(None, "--format", help="Actual export format"),
|
|
27
|
+
backend: str = typer.Option("native", "--backend", help="native / babeldoc"),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Preview built-in language operations and phase fingerprints without model calls."""
|
|
30
|
+
from wenyi_core.i18n.languages import require_language
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
config = context().load_config()
|
|
34
|
+
if source is not None:
|
|
35
|
+
config.source_lang = require_language(source, allow_auto=True)
|
|
36
|
+
preview = config.language_policy_preview(
|
|
37
|
+
format=fmt or "epub",
|
|
38
|
+
backend=backend,
|
|
39
|
+
)
|
|
40
|
+
except ValueError as error:
|
|
41
|
+
context().console.print(f"[red]Error: {error}[/]")
|
|
42
|
+
raise typer.Exit(1) from None
|
|
43
|
+
context().console.print_json(data=preview)
|
|
44
|
+
|
|
45
|
+
@app.command(rich_help_panel="State and output")
|
|
46
|
+
def languages() -> None:
|
|
47
|
+
"""List built-in translation languages (experimental) without calling a model."""
|
|
48
|
+
console = context().console
|
|
49
|
+
from wenyi_core.i18n.languages import profile, supported_languages
|
|
50
|
+
from wenyi_core.i18n.prompts import render
|
|
51
|
+
|
|
52
|
+
table = Table("Code", "Language")
|
|
53
|
+
for code in supported_languages():
|
|
54
|
+
entry = profile(code)
|
|
55
|
+
render("translator_system", src="auto", tgt=code)
|
|
56
|
+
table.add_row(code, entry["english_name"])
|
|
57
|
+
console.print(table)
|
|
58
|
+
console.print("Set language.source and language.target; source also accepts auto.")
|
|
59
|
+
|
|
60
|
+
@app.command(rich_help_panel="State and output")
|
|
61
|
+
def status(
|
|
62
|
+
input: str = typer.Argument(..., help="Source file with existing translation state"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Show chapter progress and glossary statistics."""
|
|
65
|
+
console = context().console
|
|
66
|
+
from wenyi_core.glossary.store import GlossaryStore
|
|
67
|
+
|
|
68
|
+
config = context().load_config()
|
|
69
|
+
store = runstore_for_cli(config, input, console=console)
|
|
70
|
+
if not store.exists():
|
|
71
|
+
console.print("[yellow]No progress found. Run prepare or translate first.[/]")
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
m = store.load_manifest()
|
|
74
|
+
console.print(f"“{m['title']}”({m['fmt']}) {m['source_lang']}→{m['target_lang']}")
|
|
75
|
+
table = Table("", "#", "Chapter", "Translation")
|
|
76
|
+
for c in m["chapters"]:
|
|
77
|
+
mark = "✓" if c["status"] == "done" else "·"
|
|
78
|
+
table.add_row(
|
|
79
|
+
mark,
|
|
80
|
+
str(c["index"]),
|
|
81
|
+
c["title"],
|
|
82
|
+
c["status"],
|
|
83
|
+
)
|
|
84
|
+
console.print(table)
|
|
85
|
+
g = GlossaryStore(store.glossary_path)
|
|
86
|
+
console.print("Glossary: ", g.stats())
|
|
87
|
+
g.close()
|
|
88
|
+
presentation.print_timing(console, load_timing(store.run_dir))
|
|
89
|
+
|
|
90
|
+
@app.command(rich_help_panel="State and output")
|
|
91
|
+
def inspect_run(
|
|
92
|
+
input: str = typer.Argument(..., help="Source file with existing translation state"),
|
|
93
|
+
verbose: bool = typer.Option(False, "--verbose", help="Include the last failure traceback"),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Summarize one run: failures, retries, timing and token usage, read-only."""
|
|
96
|
+
console = context().console
|
|
97
|
+
config = context().load_config()
|
|
98
|
+
store = runstore_for_cli(config, input, console=console)
|
|
99
|
+
if not store.exists():
|
|
100
|
+
console.print("[yellow]No progress found. Run prepare or translate first.[/]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
report = build_report(store)
|
|
104
|
+
console.print(f"Run: [bold]{report['run_dir']}[/]")
|
|
105
|
+
console.print(
|
|
106
|
+
f" Events {report['events']} Failures {report['failures']} "
|
|
107
|
+
f"Retries {report['retries']} Pauses {report['pauses']}"
|
|
108
|
+
)
|
|
109
|
+
failure = report["last_failure"]
|
|
110
|
+
if failure:
|
|
111
|
+
# The type is what makes a failure diagnosable; the text alone is not.
|
|
112
|
+
console.print(
|
|
113
|
+
f" Last failure: [red]{failure['error_type'] or 'unknown'}[/] {failure['error'] or ''}"
|
|
114
|
+
)
|
|
115
|
+
if verbose and failure.get("traceback"):
|
|
116
|
+
console.print(failure["traceback"])
|
|
117
|
+
else:
|
|
118
|
+
console.print(" Last failure: none")
|
|
119
|
+
|
|
120
|
+
usage = report["usage"]
|
|
121
|
+
if usage:
|
|
122
|
+
console.print(
|
|
123
|
+
" Tokens: "
|
|
124
|
+
f"in {usage.get('prompt_tokens', 0)} out {usage.get('completion_tokens', 0)} "
|
|
125
|
+
f"cache read {usage.get('cache_hit_tokens', 0)}"
|
|
126
|
+
)
|
|
127
|
+
presentation.print_timing(console, report["timing"])
|
|
128
|
+
|
|
129
|
+
@app.command(rich_help_panel="State and output")
|
|
130
|
+
def assemble(
|
|
131
|
+
input: str = typer.Argument(..., help="Source file with a complete or partial translation"),
|
|
132
|
+
out: str | None = typer.Option(
|
|
133
|
+
None,
|
|
134
|
+
"--out",
|
|
135
|
+
help="Monolingual output path; defaults to the output directory beside the source",
|
|
136
|
+
),
|
|
137
|
+
fmt: str | None = typer.Option(
|
|
138
|
+
None,
|
|
139
|
+
"--format",
|
|
140
|
+
help="Output format: epub / txt / html / markdown / docx / pdf (BabelDOC only); default: pdf for BabelDOC PDF state, docx for .docx input, epub otherwise",
|
|
141
|
+
),
|
|
142
|
+
mono: bool | None = typer.Option(
|
|
143
|
+
None,
|
|
144
|
+
"--mono/--no-mono",
|
|
145
|
+
help="Override output.mono to enable or disable monolingual output",
|
|
146
|
+
),
|
|
147
|
+
bilingual: bool | None = typer.Option(
|
|
148
|
+
None,
|
|
149
|
+
"--bilingual/--no-bilingual",
|
|
150
|
+
help="Override output.bilingual to enable or disable bilingual output",
|
|
151
|
+
),
|
|
152
|
+
):
|
|
153
|
+
"""Export translations from existing state without calling a model."""
|
|
154
|
+
from wenyi_core.models.errors import IngestError
|
|
155
|
+
|
|
156
|
+
console = context().console
|
|
157
|
+
from wenyi_core.llm.providers.fake import FakeClient
|
|
158
|
+
from wenyi_core.pipeline.orchestrator import Orchestrator
|
|
159
|
+
|
|
160
|
+
config = context().load_config()
|
|
161
|
+
require_input_file(input, console=console)
|
|
162
|
+
fmt = resolve_output_format(input, fmt, console=console)
|
|
163
|
+
if mono is not None:
|
|
164
|
+
config.output.mono = mono
|
|
165
|
+
if bilingual is not None:
|
|
166
|
+
config.output.bilingual = bilingual
|
|
167
|
+
try:
|
|
168
|
+
result = Orchestrator(config, client=FakeClient()).run_assemble(
|
|
169
|
+
input,
|
|
170
|
+
out_format=fmt,
|
|
171
|
+
out_path=out,
|
|
172
|
+
)
|
|
173
|
+
except (IngestError, OSError, ValueError) as error:
|
|
174
|
+
console.print(f"[red]Error: {error}[/]")
|
|
175
|
+
raise typer.Exit(1) from None
|
|
176
|
+
paths = result["outputs"]
|
|
177
|
+
presentation.print_timing(console, load_timing(result["store"].run_dir))
|
|
178
|
+
for path in paths:
|
|
179
|
+
console.print(f"Translation written: [bold]{path}[/]")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Atomic, versioned CLI receipts for external process supervisors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from wenyi_core.atomicio import publish_bytes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def file_digest(path: Path) -> str:
|
|
15
|
+
digest = hashlib.sha256()
|
|
16
|
+
with path.open("rb") as handle:
|
|
17
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
18
|
+
digest.update(block)
|
|
19
|
+
return digest.hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MachineOutput:
|
|
23
|
+
"""Publish progress and receipts without exposing configuration or model responses."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
progress_path: str | None,
|
|
28
|
+
result_path: str | None,
|
|
29
|
+
*,
|
|
30
|
+
source: str,
|
|
31
|
+
config: str,
|
|
32
|
+
output: str | None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.progress_path = Path(progress_path).resolve() if progress_path else None
|
|
35
|
+
self.result_path = Path(result_path).resolve() if result_path else None
|
|
36
|
+
self.source = Path(source).resolve()
|
|
37
|
+
self.run_id = uuid4().hex
|
|
38
|
+
self.completed = False
|
|
39
|
+
paths = [path for path in (self.progress_path, self.result_path) if path is not None]
|
|
40
|
+
protected = {self.source, Path(config).resolve()}
|
|
41
|
+
if output:
|
|
42
|
+
protected.add(Path(output).resolve())
|
|
43
|
+
if len(set(paths)) != len(paths) or protected.intersection(paths):
|
|
44
|
+
raise ValueError("Machine output paths must be distinct from source, config and output")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def enabled(self) -> bool:
|
|
48
|
+
return self.progress_path is not None or self.result_path is not None
|
|
49
|
+
|
|
50
|
+
def _write(self, path: Path | None, **data) -> None:
|
|
51
|
+
if path is None:
|
|
52
|
+
return
|
|
53
|
+
payload = {
|
|
54
|
+
"protocol_version": 1,
|
|
55
|
+
"run_id": self.run_id,
|
|
56
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
57
|
+
**data,
|
|
58
|
+
}
|
|
59
|
+
publish_bytes(path, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
|
60
|
+
|
|
61
|
+
def start(self) -> None:
|
|
62
|
+
# Replace an old receipt before preflight so it cannot masquerade as this run.
|
|
63
|
+
self._write(self.result_path, status="running", outputs=[])
|
|
64
|
+
self(0, 0, "Starting Wenyi")
|
|
65
|
+
|
|
66
|
+
def __call__(self, done: int, total: int, label: str) -> None:
|
|
67
|
+
self._write(
|
|
68
|
+
self.progress_path,
|
|
69
|
+
status="running",
|
|
70
|
+
done=done,
|
|
71
|
+
total=total if total > 0 else None,
|
|
72
|
+
label=label,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def success(self, paths: list[str], state_dir: str) -> None:
|
|
76
|
+
if not self.enabled:
|
|
77
|
+
self.completed = True
|
|
78
|
+
return
|
|
79
|
+
outputs = []
|
|
80
|
+
for name in paths:
|
|
81
|
+
path = Path(name).resolve()
|
|
82
|
+
outputs.append(
|
|
83
|
+
{"path": str(path), "size": path.stat().st_size, "sha256": file_digest(path)}
|
|
84
|
+
)
|
|
85
|
+
self._write(
|
|
86
|
+
self.result_path,
|
|
87
|
+
status="success",
|
|
88
|
+
source_sha256=file_digest(self.source),
|
|
89
|
+
state_dir=str(Path(state_dir).resolve()),
|
|
90
|
+
outputs=outputs,
|
|
91
|
+
)
|
|
92
|
+
self._write(self.progress_path, status="success", done=1, total=1, label="Completed")
|
|
93
|
+
self.completed = True
|
|
94
|
+
|
|
95
|
+
def failed(self) -> None:
|
|
96
|
+
self._write(
|
|
97
|
+
self.result_path,
|
|
98
|
+
status="failed",
|
|
99
|
+
outputs=[],
|
|
100
|
+
error={"code": "translation_failed", "message": "See the Wenyi log for details."},
|
|
101
|
+
)
|
|
102
|
+
self._write(self.progress_path, status="failed", done=0, total=None, label="Stopped")
|