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 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()
@@ -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