tinbox 0.1.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.
- tinbox/__init__.py +9 -0
- tinbox/cli.py +307 -0
- tinbox/core/__init__.py +26 -0
- tinbox/core/cost.py +158 -0
- tinbox/core/output.py +212 -0
- tinbox/core/processor/__init__.py +230 -0
- tinbox/core/processor/docx.py +172 -0
- tinbox/core/processor/pdf.py +137 -0
- tinbox/core/processor/text.py +151 -0
- tinbox/core/progress.py +148 -0
- tinbox/core/translation/__init__.py +39 -0
- tinbox/core/translation/algorithms.py +598 -0
- tinbox/core/translation/checkpoint.py +223 -0
- tinbox/core/translation/interface.py +70 -0
- tinbox/core/translation/litellm.py +326 -0
- tinbox/core/types.py +108 -0
- tinbox/utils/language.py +161 -0
- tinbox/utils/logging.py +50 -0
- tinbox-0.1.0.dist-info/METADATA +290 -0
- tinbox-0.1.0.dist-info/RECORD +23 -0
- tinbox-0.1.0.dist-info/WHEEL +4 -0
- tinbox-0.1.0.dist-info/entry_points.txt +2 -0
- tinbox-0.1.0.dist-info/licenses/LICENSE +201 -0
tinbox/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Tinbox - A CLI Translation Tool using LLMs.
|
|
2
|
+
|
|
3
|
+
This package provides functionality for translating documents using various LLM models,
|
|
4
|
+
supporting multiple input formats and translation algorithms.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__author__ = "strickvl"
|
|
9
|
+
__license__ = "MIT"
|
tinbox/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Command-line interface for Tinbox."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from tinbox.core import (
|
|
15
|
+
FileType,
|
|
16
|
+
ModelType,
|
|
17
|
+
TranslationConfig,
|
|
18
|
+
TranslationResult,
|
|
19
|
+
translate_document,
|
|
20
|
+
)
|
|
21
|
+
from tinbox.core.cost import estimate_cost
|
|
22
|
+
from tinbox.core.processor import load_document
|
|
23
|
+
from tinbox.core.translation import create_translator
|
|
24
|
+
from tinbox.core.output import (
|
|
25
|
+
OutputFormat,
|
|
26
|
+
TranslationMetadata,
|
|
27
|
+
TranslationOutput,
|
|
28
|
+
create_handler,
|
|
29
|
+
)
|
|
30
|
+
from tinbox.utils.logging import configure_logging, get_logger
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="tinbox",
|
|
34
|
+
help="A CLI tool for translating documents using LLMs",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
)
|
|
37
|
+
console = Console()
|
|
38
|
+
logger = get_logger()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def version_callback(value: bool) -> None:
|
|
42
|
+
"""Print version information and exit."""
|
|
43
|
+
if value:
|
|
44
|
+
from tinbox import __version__
|
|
45
|
+
|
|
46
|
+
console.print(f"Tinbox version: {__version__}")
|
|
47
|
+
raise typer.Exit(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def main(
|
|
52
|
+
version: Optional[bool] = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--version",
|
|
55
|
+
"-v",
|
|
56
|
+
help="Show version information and exit.",
|
|
57
|
+
callback=version_callback,
|
|
58
|
+
is_eager=True,
|
|
59
|
+
),
|
|
60
|
+
log_level: str = typer.Option(
|
|
61
|
+
"INFO",
|
|
62
|
+
"--log-level",
|
|
63
|
+
"-l",
|
|
64
|
+
help="Set the logging level.",
|
|
65
|
+
),
|
|
66
|
+
json_logs: bool = typer.Option(
|
|
67
|
+
False,
|
|
68
|
+
"--json",
|
|
69
|
+
"-j",
|
|
70
|
+
help="Output logs in JSON format.",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Tinbox - A CLI Translation Tool using LLMs."""
|
|
74
|
+
configure_logging(level=log_level, json=json_logs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def display_cost_estimate(estimate, model: ModelType) -> None:
|
|
78
|
+
"""Display cost estimate in a rich format."""
|
|
79
|
+
table = Table(title="Cost Estimate", show_header=False)
|
|
80
|
+
table.add_column("Metric", style="bold blue")
|
|
81
|
+
table.add_column("Value")
|
|
82
|
+
|
|
83
|
+
table.add_row("Estimated Tokens", f"{estimate.estimated_tokens:,}")
|
|
84
|
+
if model != ModelType.OLLAMA:
|
|
85
|
+
table.add_row("Estimated Cost", f"${estimate.estimated_cost:.2f}")
|
|
86
|
+
table.add_row("Estimated Time", f"{estimate.estimated_time / 60:.1f} minutes")
|
|
87
|
+
table.add_row("Cost Level", estimate.cost_level.value.title())
|
|
88
|
+
|
|
89
|
+
console.print(table)
|
|
90
|
+
|
|
91
|
+
if estimate.warnings:
|
|
92
|
+
console.print("\n[yellow]Warnings:[/yellow]")
|
|
93
|
+
for warning in estimate.warnings:
|
|
94
|
+
console.print(f"• {warning}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_model_spec(model_spec: str) -> tuple[ModelType, str]:
|
|
98
|
+
"""Parse a model specification string.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
model_spec: Model specification (e.g., 'openai:gpt-4o', 'anthropic:claude-3-sonnet', 'ollama:mistral-small')
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Tuple of (ModelType, str) for the provider and model name.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: If the model specification is invalid
|
|
108
|
+
"""
|
|
109
|
+
if ":" not in model_spec:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Invalid model specification. Use format 'provider:model' "
|
|
112
|
+
"(e.g., 'openai:gpt-4o', 'anthropic:claude-3-sonnet', 'ollama:mistral-small')"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
provider, model = model_spec.split(":", 1)
|
|
116
|
+
try:
|
|
117
|
+
return ModelType(provider.lower()), model
|
|
118
|
+
except ValueError:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Unknown model provider: {provider}. "
|
|
121
|
+
f"Supported providers: {', '.join(m.value for m in ModelType)}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def translate(
|
|
127
|
+
input_file: Path = typer.Argument(
|
|
128
|
+
...,
|
|
129
|
+
help="The input file to translate.",
|
|
130
|
+
exists=True,
|
|
131
|
+
file_okay=True,
|
|
132
|
+
dir_okay=False,
|
|
133
|
+
resolve_path=True,
|
|
134
|
+
),
|
|
135
|
+
output_file: Optional[Path] = typer.Option(
|
|
136
|
+
None,
|
|
137
|
+
"--output",
|
|
138
|
+
"-o",
|
|
139
|
+
help="The output file. If not specified, prints to stdout.",
|
|
140
|
+
),
|
|
141
|
+
output_format: OutputFormat = typer.Option(
|
|
142
|
+
OutputFormat.TEXT,
|
|
143
|
+
"--format",
|
|
144
|
+
"-F",
|
|
145
|
+
help="Output format (text, json, or markdown).",
|
|
146
|
+
),
|
|
147
|
+
source_lang: str = typer.Option(
|
|
148
|
+
None,
|
|
149
|
+
"--from",
|
|
150
|
+
"-f",
|
|
151
|
+
help="Source language code (e.g., 'en', 'zh'). Defaults to auto-detect.",
|
|
152
|
+
),
|
|
153
|
+
target_lang: str = typer.Option(
|
|
154
|
+
"en",
|
|
155
|
+
"--to",
|
|
156
|
+
"-t",
|
|
157
|
+
help="Target language code. Defaults to 'en' (English).",
|
|
158
|
+
),
|
|
159
|
+
model: str = typer.Option(
|
|
160
|
+
"anthropic:claude-3-sonnet",
|
|
161
|
+
"--model",
|
|
162
|
+
"-m",
|
|
163
|
+
help="Model to use (e.g., 'openai:gpt-4o', 'anthropic:claude-3-sonnet', 'ollama:mistral-small').",
|
|
164
|
+
),
|
|
165
|
+
algorithm: str = typer.Option(
|
|
166
|
+
"page",
|
|
167
|
+
"--algorithm",
|
|
168
|
+
"-a",
|
|
169
|
+
help="Translation algorithm to use: 'page' or 'sliding-window'.",
|
|
170
|
+
),
|
|
171
|
+
dry_run: bool = typer.Option(
|
|
172
|
+
False,
|
|
173
|
+
"--dry-run",
|
|
174
|
+
help="Estimate cost and tokens without performing translation.",
|
|
175
|
+
),
|
|
176
|
+
force: bool = typer.Option(
|
|
177
|
+
False,
|
|
178
|
+
"--force",
|
|
179
|
+
help="Skip warnings and proceed with translation.",
|
|
180
|
+
),
|
|
181
|
+
max_cost: Optional[float] = typer.Option(
|
|
182
|
+
None,
|
|
183
|
+
"--max-cost",
|
|
184
|
+
help="Maximum cost threshold in USD.",
|
|
185
|
+
),
|
|
186
|
+
verbose: bool = typer.Option(
|
|
187
|
+
False,
|
|
188
|
+
"--verbose",
|
|
189
|
+
help="Show detailed progress information.",
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Translate a document using LLMs."""
|
|
193
|
+
try:
|
|
194
|
+
# Parse model specification
|
|
195
|
+
model_type, model_name = parse_model_spec(model)
|
|
196
|
+
|
|
197
|
+
# Determine file type
|
|
198
|
+
file_type = FileType(input_file.suffix.lstrip(".").lower())
|
|
199
|
+
|
|
200
|
+
# Get cost estimate
|
|
201
|
+
estimate = estimate_cost(
|
|
202
|
+
input_file,
|
|
203
|
+
model_type,
|
|
204
|
+
max_cost=max_cost,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Display cost estimate
|
|
208
|
+
console.print("\n[bold]Translation Plan[/bold]")
|
|
209
|
+
display_cost_estimate(estimate, model_type)
|
|
210
|
+
|
|
211
|
+
# In dry-run mode, exit after showing estimate
|
|
212
|
+
if dry_run:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Check for warnings and confirm if needed
|
|
216
|
+
if not force and estimate.warnings:
|
|
217
|
+
console.print(
|
|
218
|
+
"\n[yellow]Warning:[/yellow] This translation has potential issues."
|
|
219
|
+
)
|
|
220
|
+
proceed = typer.confirm("Do you want to proceed?")
|
|
221
|
+
if not proceed:
|
|
222
|
+
console.print("\nTranslation cancelled.")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
# Create translation config
|
|
226
|
+
config = TranslationConfig(
|
|
227
|
+
source_lang=source_lang or "auto",
|
|
228
|
+
target_lang=target_lang,
|
|
229
|
+
model=model_type,
|
|
230
|
+
model_name=model_name,
|
|
231
|
+
algorithm=algorithm,
|
|
232
|
+
input_file=input_file,
|
|
233
|
+
output_file=output_file,
|
|
234
|
+
force=force,
|
|
235
|
+
max_cost=max_cost,
|
|
236
|
+
verbose=verbose,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Load document
|
|
240
|
+
content = asyncio.run(load_document(input_file))
|
|
241
|
+
|
|
242
|
+
# Initialize model interface
|
|
243
|
+
translator = create_translator(config)
|
|
244
|
+
|
|
245
|
+
# Show progress
|
|
246
|
+
with Progress(
|
|
247
|
+
SpinnerColumn(),
|
|
248
|
+
TextColumn("[progress.description]{task.description}"),
|
|
249
|
+
console=console,
|
|
250
|
+
) as progress:
|
|
251
|
+
# Run translation
|
|
252
|
+
response = asyncio.run(
|
|
253
|
+
translate_document(
|
|
254
|
+
content=content,
|
|
255
|
+
config=config,
|
|
256
|
+
translator=translator,
|
|
257
|
+
progress=progress,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Convert TranslationResponse to TranslationResult
|
|
262
|
+
result = TranslationResult(
|
|
263
|
+
text=response.text,
|
|
264
|
+
tokens_used=response.tokens_used,
|
|
265
|
+
cost=response.cost,
|
|
266
|
+
time_taken=response.time_taken,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Create translation output with metadata
|
|
270
|
+
output = TranslationOutput(
|
|
271
|
+
metadata=TranslationMetadata(
|
|
272
|
+
source_lang=config.source_lang,
|
|
273
|
+
target_lang=config.target_lang,
|
|
274
|
+
model=config.model,
|
|
275
|
+
algorithm=config.algorithm,
|
|
276
|
+
input_file=config.input_file,
|
|
277
|
+
input_file_type=file_type,
|
|
278
|
+
),
|
|
279
|
+
result=result,
|
|
280
|
+
warnings=estimate.warnings,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Get appropriate output handler
|
|
284
|
+
handler = create_handler(output_format)
|
|
285
|
+
handler.write(output, output_file)
|
|
286
|
+
|
|
287
|
+
# Show final statistics (only for text output)
|
|
288
|
+
if output_format == OutputFormat.TEXT:
|
|
289
|
+
table = Table(title="Translation Statistics", show_header=False)
|
|
290
|
+
table.add_column("Metric", style="bold blue")
|
|
291
|
+
table.add_column("Value")
|
|
292
|
+
|
|
293
|
+
table.add_row("Time Taken", f"{result.time_taken:.1f} seconds")
|
|
294
|
+
table.add_row("Tokens Used", f"{result.tokens_used:,}")
|
|
295
|
+
if model_type != ModelType.OLLAMA:
|
|
296
|
+
table.add_row("Final Cost", f"${result.cost:.4f}")
|
|
297
|
+
|
|
298
|
+
console.print("\n", table)
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.exception("Translation failed")
|
|
302
|
+
console.print(f"[red]Error: {str(e)}[/red]")
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
app()
|
tinbox/core/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Core functionality for Tinbox."""
|
|
2
|
+
|
|
3
|
+
from tinbox.core.types import (
|
|
4
|
+
FileType,
|
|
5
|
+
ModelType,
|
|
6
|
+
TranslationConfig,
|
|
7
|
+
TranslationResult,
|
|
8
|
+
)
|
|
9
|
+
from tinbox.core.processor import DocumentContent, load_document
|
|
10
|
+
from tinbox.core.translation import (
|
|
11
|
+
ModelInterface,
|
|
12
|
+
TranslationRequest,
|
|
13
|
+
TranslationResponse,
|
|
14
|
+
create_translator,
|
|
15
|
+
)
|
|
16
|
+
from tinbox.core.translation.algorithms import translate_document
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"FileType",
|
|
20
|
+
"ModelType",
|
|
21
|
+
"TranslationConfig",
|
|
22
|
+
"TranslationResult",
|
|
23
|
+
"DocumentContent",
|
|
24
|
+
"load_document",
|
|
25
|
+
"translate_document",
|
|
26
|
+
]
|
tinbox/core/cost.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Cost estimation utilities."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
from tinbox.core.types import FileType, ModelType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CostLevel(str, Enum):
|
|
11
|
+
"""Cost level classification."""
|
|
12
|
+
|
|
13
|
+
LOW = "low" # < $1
|
|
14
|
+
MEDIUM = "medium" # $1-$5
|
|
15
|
+
HIGH = "high" # $5-$20
|
|
16
|
+
VERY_HIGH = "very_high" # > $20
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Approximate costs per 1K tokens (as of March 2024)
|
|
20
|
+
MODEL_COSTS: Dict[ModelType, float] = {
|
|
21
|
+
ModelType.OPENAI: 0.03, # $0.03 per 1K input tokens, $0.06 per 1K output tokens
|
|
22
|
+
ModelType.ANTHROPIC: 0.003, # $0.003 per 1K input tokens, $0.015 per 1K output tokens
|
|
23
|
+
ModelType.OLLAMA: 0.0, # Free for local models
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate_document_tokens(file_path: Path) -> int:
|
|
28
|
+
"""Estimate the number of tokens in a document.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
file_path: Path to the document
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Estimated number of tokens
|
|
35
|
+
|
|
36
|
+
Note:
|
|
37
|
+
These are rough estimates:
|
|
38
|
+
- PDF: 500 tokens per page
|
|
39
|
+
- DOCX: 1.3 tokens per word, rounded up
|
|
40
|
+
- TXT: 1 token per 4 characters, rounded up
|
|
41
|
+
"""
|
|
42
|
+
file_type = FileType(file_path.suffix.lstrip(".").lower())
|
|
43
|
+
|
|
44
|
+
# Read sample of document to estimate tokens
|
|
45
|
+
if file_type == FileType.PDF:
|
|
46
|
+
# For PDFs, estimate based on number of pages
|
|
47
|
+
import pypdf
|
|
48
|
+
|
|
49
|
+
with open(file_path, "rb") as f:
|
|
50
|
+
pdf = pypdf.PdfReader(f)
|
|
51
|
+
# Rough estimate: 500 tokens per page
|
|
52
|
+
return len(pdf.pages) * 500
|
|
53
|
+
elif file_type == FileType.DOCX:
|
|
54
|
+
# For DOCX, estimate based on word count
|
|
55
|
+
from docx import Document
|
|
56
|
+
|
|
57
|
+
doc = Document(file_path)
|
|
58
|
+
word_count = sum(len(p.text.split()) for p in doc.paragraphs)
|
|
59
|
+
# Rough estimate: 1.3 tokens per word, rounded up
|
|
60
|
+
return int(word_count * 1.3 + 0.999) # Round up by adding 0.999
|
|
61
|
+
else: # TXT
|
|
62
|
+
# For text files, estimate based on character count
|
|
63
|
+
text = file_path.read_text()
|
|
64
|
+
# Rough estimate: 1 token per 4 characters, rounded up
|
|
65
|
+
return -(-len(text) // 4) # Ceiling division
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_cost_level(cost: float) -> CostLevel:
|
|
69
|
+
"""Get the cost level classification.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
cost: Estimated cost in USD
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Cost level classification
|
|
76
|
+
"""
|
|
77
|
+
if cost < 1.0:
|
|
78
|
+
return CostLevel.LOW
|
|
79
|
+
elif cost < 5.0:
|
|
80
|
+
return CostLevel.MEDIUM
|
|
81
|
+
elif cost < 20.0:
|
|
82
|
+
return CostLevel.HIGH
|
|
83
|
+
else:
|
|
84
|
+
return CostLevel.VERY_HIGH
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CostEstimate:
|
|
88
|
+
"""Cost estimate for a translation task."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
estimated_tokens: int,
|
|
93
|
+
estimated_cost: float,
|
|
94
|
+
estimated_time: float,
|
|
95
|
+
warnings: list[str],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Initialize cost estimate.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
estimated_tokens: Estimated number of tokens
|
|
101
|
+
estimated_cost: Estimated cost in USD
|
|
102
|
+
estimated_time: Estimated time in seconds
|
|
103
|
+
warnings: List of warning messages
|
|
104
|
+
"""
|
|
105
|
+
self.estimated_tokens = estimated_tokens
|
|
106
|
+
self.estimated_cost = estimated_cost
|
|
107
|
+
self.estimated_time = estimated_time
|
|
108
|
+
self.warnings = warnings
|
|
109
|
+
self.cost_level = get_cost_level(estimated_cost)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def estimate_cost(
|
|
113
|
+
file_path: Path,
|
|
114
|
+
model: ModelType,
|
|
115
|
+
*,
|
|
116
|
+
max_cost: Optional[float] = None,
|
|
117
|
+
) -> CostEstimate:
|
|
118
|
+
"""Estimate the cost of translating a document.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
file_path: Path to the document
|
|
122
|
+
model: Model to use for translation
|
|
123
|
+
max_cost: Optional maximum cost threshold
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
CostEstimate object with token count, cost, and warnings
|
|
127
|
+
"""
|
|
128
|
+
estimated_tokens = estimate_document_tokens(file_path)
|
|
129
|
+
cost_per_1k = MODEL_COSTS.get(model, 0.0)
|
|
130
|
+
estimated_cost = (estimated_tokens / 1000) * cost_per_1k
|
|
131
|
+
|
|
132
|
+
# Estimate time (very rough estimate)
|
|
133
|
+
# Assume 5 tokens/second for cloud models, 20 tokens/second for local
|
|
134
|
+
tokens_per_second = 20 if model == ModelType.OLLAMA else 5
|
|
135
|
+
estimated_time = estimated_tokens / tokens_per_second
|
|
136
|
+
|
|
137
|
+
warnings = []
|
|
138
|
+
|
|
139
|
+
# Generate warnings
|
|
140
|
+
if model != ModelType.OLLAMA:
|
|
141
|
+
if estimated_tokens > 50000: # More than 50K tokens
|
|
142
|
+
warnings.append(
|
|
143
|
+
f"Large document detected ({estimated_tokens:,} tokens). "
|
|
144
|
+
"Consider using Ollama for better performance and no cost."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if max_cost and estimated_cost > max_cost:
|
|
148
|
+
warnings.append(
|
|
149
|
+
f"Estimated cost (${estimated_cost:.2f}) exceeds maximum "
|
|
150
|
+
f"threshold (${max_cost:.2f})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return CostEstimate(
|
|
154
|
+
estimated_tokens=estimated_tokens,
|
|
155
|
+
estimated_cost=estimated_cost,
|
|
156
|
+
estimated_time=estimated_time,
|
|
157
|
+
warnings=warnings,
|
|
158
|
+
)
|
tinbox/core/output.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Output handlers for different formats."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional, Protocol, Union
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from tinbox.core.types import FileType, ModelType, TranslationResult
|
|
12
|
+
from tinbox.utils.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutputFormat(str, Enum):
|
|
18
|
+
"""Supported output formats."""
|
|
19
|
+
|
|
20
|
+
TEXT = "text"
|
|
21
|
+
JSON = "json"
|
|
22
|
+
MARKDOWN = "markdown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TranslationMetadata(BaseModel):
|
|
26
|
+
"""Metadata about the translation process."""
|
|
27
|
+
|
|
28
|
+
source_lang: str
|
|
29
|
+
target_lang: str
|
|
30
|
+
model: ModelType
|
|
31
|
+
algorithm: str
|
|
32
|
+
input_file: Path
|
|
33
|
+
input_file_type: FileType
|
|
34
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TranslationOutput(BaseModel):
|
|
38
|
+
"""Complete translation output including metadata."""
|
|
39
|
+
|
|
40
|
+
metadata: TranslationMetadata
|
|
41
|
+
result: TranslationResult
|
|
42
|
+
warnings: list[str] = Field(default_factory=list)
|
|
43
|
+
errors: list[str] = Field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OutputHandler(Protocol):
|
|
47
|
+
"""Protocol for output handlers."""
|
|
48
|
+
|
|
49
|
+
def write(
|
|
50
|
+
self,
|
|
51
|
+
output: TranslationOutput,
|
|
52
|
+
file: Optional[Path] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Write the translation output.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
output: The translation output to write
|
|
58
|
+
file: Optional file to write to. If None, writes to stdout.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JSONOutputHandler:
|
|
64
|
+
"""Handler for JSON output format."""
|
|
65
|
+
|
|
66
|
+
def write(
|
|
67
|
+
self,
|
|
68
|
+
output: TranslationOutput,
|
|
69
|
+
file: Optional[Path] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Write translation output as JSON.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
output: The translation output to write
|
|
75
|
+
file: Optional file to write to. If None, writes to stdout.
|
|
76
|
+
"""
|
|
77
|
+
# Convert to JSON-serializable dict
|
|
78
|
+
data = output.model_dump(
|
|
79
|
+
mode="json",
|
|
80
|
+
exclude_none=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Convert Path objects to strings
|
|
84
|
+
data["metadata"]["input_file"] = str(data["metadata"]["input_file"])
|
|
85
|
+
if "output_file" in data["metadata"]:
|
|
86
|
+
data["metadata"]["output_file"] = str(data["metadata"]["output_file"])
|
|
87
|
+
|
|
88
|
+
# Format JSON with indentation
|
|
89
|
+
json_str = json.dumps(data, indent=2)
|
|
90
|
+
|
|
91
|
+
if file:
|
|
92
|
+
file.write_text(json_str)
|
|
93
|
+
else:
|
|
94
|
+
print(json_str)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TextOutputHandler:
|
|
98
|
+
"""Handler for plain text output format."""
|
|
99
|
+
|
|
100
|
+
def write(
|
|
101
|
+
self,
|
|
102
|
+
output: TranslationOutput,
|
|
103
|
+
file: Optional[Path] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Write translation output as plain text.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
output: The translation output to write
|
|
109
|
+
file: Optional file to write to. If None, writes to stdout.
|
|
110
|
+
"""
|
|
111
|
+
# Just write the translated text
|
|
112
|
+
if file:
|
|
113
|
+
file.write_text(output.result.text)
|
|
114
|
+
else:
|
|
115
|
+
print(output.result.text)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MarkdownOutputHandler:
|
|
119
|
+
"""Handler for Markdown output format."""
|
|
120
|
+
|
|
121
|
+
def write(
|
|
122
|
+
self,
|
|
123
|
+
output: TranslationOutput,
|
|
124
|
+
file: Optional[Path] = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Write translation output as Markdown.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
output: The translation output to write
|
|
130
|
+
file: Optional file to write to. If None, writes to stdout.
|
|
131
|
+
"""
|
|
132
|
+
# Build Markdown content
|
|
133
|
+
md_lines = [
|
|
134
|
+
"# Translation Results\n",
|
|
135
|
+
"## Metadata",
|
|
136
|
+
f"- Source Language: {output.metadata.source_lang}",
|
|
137
|
+
f"- Target Language: {output.metadata.target_lang}",
|
|
138
|
+
f"- Model: {output.metadata.model.value}",
|
|
139
|
+
f"- Algorithm: {output.metadata.algorithm}",
|
|
140
|
+
f"- Input File: {output.metadata.input_file.name}",
|
|
141
|
+
f"- File Type: {output.metadata.input_file_type.value}",
|
|
142
|
+
f"- Timestamp: {output.metadata.timestamp.isoformat()}\n",
|
|
143
|
+
"## Translation",
|
|
144
|
+
"```text",
|
|
145
|
+
output.result.text,
|
|
146
|
+
"```\n",
|
|
147
|
+
"## Statistics",
|
|
148
|
+
f"- Tokens Used: {output.result.tokens_used:,}",
|
|
149
|
+
f"- Cost: ${output.result.cost:.4f}",
|
|
150
|
+
f"- Time Taken: {output.result.time_taken:.1f}s\n",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
# Add warnings if any
|
|
154
|
+
if output.warnings:
|
|
155
|
+
md_lines.extend(
|
|
156
|
+
[
|
|
157
|
+
"## Warnings",
|
|
158
|
+
*[f"- {warning}" for warning in output.warnings],
|
|
159
|
+
"",
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Add errors if any
|
|
164
|
+
if output.errors:
|
|
165
|
+
md_lines.extend(
|
|
166
|
+
[
|
|
167
|
+
"## Errors",
|
|
168
|
+
*[f"- {error}" for error in output.errors],
|
|
169
|
+
"",
|
|
170
|
+
]
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
md_lines.extend(
|
|
174
|
+
[
|
|
175
|
+
"## Errors",
|
|
176
|
+
"[None]",
|
|
177
|
+
"",
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Join lines with newlines
|
|
182
|
+
md_content = "\n".join(md_lines)
|
|
183
|
+
|
|
184
|
+
if file:
|
|
185
|
+
file.write_text(md_content)
|
|
186
|
+
else:
|
|
187
|
+
print(md_content)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def create_handler(format: OutputFormat) -> OutputHandler:
|
|
191
|
+
"""Create an output handler for the specified format.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
format: The desired output format
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
An appropriate output handler
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValueError: If the format is not supported
|
|
201
|
+
"""
|
|
202
|
+
handlers = {
|
|
203
|
+
OutputFormat.TEXT: TextOutputHandler(),
|
|
204
|
+
OutputFormat.JSON: JSONOutputHandler(),
|
|
205
|
+
OutputFormat.MARKDOWN: MarkdownOutputHandler(),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
handler = handlers.get(format)
|
|
209
|
+
if not handler:
|
|
210
|
+
raise ValueError(f"Unsupported output format: {format}")
|
|
211
|
+
|
|
212
|
+
return handler
|