Upload 6 files
Browse files- .env.example +1 -0
- __init__.py +1 -0
- app.py +319 -0
- modal_deploy.py +316 -0
- requirements.txt +24 -0
- server.py +511 -0
.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
BABELDOCS_MODAL_URL=https://your-workspace.modal.run
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# A simple MCP server for document translation with layout preservation
|
app.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BabelDocs x Agentic AI MCP - Gradio Application
|
| 3 |
+
|
| 4 |
+
PDF Translation with Google Drive Integration.
|
| 5 |
+
Accepts public GDrive links or local file uploads.
|
| 6 |
+
|
| 7 |
+
For Anthropic Hackathon - Track 1: Building MCP
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python app.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
import base64
|
| 16 |
+
import tempfile
|
| 17 |
+
import httpx
|
| 18 |
+
import gradio as gr
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from dotenv import load_dotenv
|
| 22 |
+
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
# Modal endpoint configuration
|
| 26 |
+
# Set BABELDOCS_MODAL_URL as HuggingFace Space secret for production
|
| 27 |
+
MODAL_BASE_URL = os.getenv("BABELDOCS_MODAL_URL")
|
| 28 |
+
if not MODAL_BASE_URL:
|
| 29 |
+
raise ValueError("BABELDOCS_MODAL_URL environment variable required. Set it as a HuggingFace Space secret.")
|
| 30 |
+
MODAL_TRANSLATE_URL = f"{MODAL_BASE_URL}-babeldocstranslator-api.modal.run"
|
| 31 |
+
MODAL_HEALTH_URL = f"{MODAL_BASE_URL}-babeldocstranslator-health.modal.run"
|
| 32 |
+
|
| 33 |
+
# Max pages limit (test phase)
|
| 34 |
+
MAX_PAGES = 20
|
| 35 |
+
|
| 36 |
+
# Supported languages
|
| 37 |
+
LANGUAGES = {
|
| 38 |
+
"fr": "French",
|
| 39 |
+
"en": "English",
|
| 40 |
+
"es": "Spanish",
|
| 41 |
+
"de": "German",
|
| 42 |
+
"it": "Italian",
|
| 43 |
+
"pt": "Portuguese",
|
| 44 |
+
"zh": "Chinese",
|
| 45 |
+
"ja": "Japanese",
|
| 46 |
+
"ko": "Korean",
|
| 47 |
+
"ru": "Russian",
|
| 48 |
+
"ar": "Arabic",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def log_message(logs: list, message: str) -> list:
|
| 53 |
+
"""Add timestamped message to logs."""
|
| 54 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 55 |
+
logs.append(f"[{timestamp}] {message}")
|
| 56 |
+
return logs
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def extract_gdrive_file_id(url: str) -> str | None:
|
| 60 |
+
"""Extract file ID from Google Drive URL."""
|
| 61 |
+
patterns = [
|
| 62 |
+
r"/file/d/([a-zA-Z0-9_-]+)",
|
| 63 |
+
r"id=([a-zA-Z0-9_-]+)",
|
| 64 |
+
r"/d/([a-zA-Z0-9_-]+)",
|
| 65 |
+
]
|
| 66 |
+
for pattern in patterns:
|
| 67 |
+
match = re.search(pattern, url)
|
| 68 |
+
if match:
|
| 69 |
+
return match.group(1)
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def download_gdrive_public(url: str) -> tuple[bytes, str]:
|
| 74 |
+
"""Download file from public Google Drive link.
|
| 75 |
+
|
| 76 |
+
Returns (file_bytes, filename).
|
| 77 |
+
"""
|
| 78 |
+
file_id = extract_gdrive_file_id(url)
|
| 79 |
+
if not file_id:
|
| 80 |
+
raise ValueError("Invalid Google Drive URL")
|
| 81 |
+
|
| 82 |
+
# Direct download URL
|
| 83 |
+
download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
|
| 84 |
+
|
| 85 |
+
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
| 86 |
+
response = await client.get(download_url)
|
| 87 |
+
response.raise_for_status()
|
| 88 |
+
|
| 89 |
+
# Try to get filename from Content-Disposition header
|
| 90 |
+
content_disp = response.headers.get("Content-Disposition", "")
|
| 91 |
+
filename_match = re.search(r'filename="?([^";\n]+)"?', content_disp)
|
| 92 |
+
if filename_match:
|
| 93 |
+
filename = filename_match.group(1)
|
| 94 |
+
else:
|
| 95 |
+
filename = f"gdrive_{file_id}.pdf"
|
| 96 |
+
|
| 97 |
+
return response.content, filename
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def translate_pdf_modal(
|
| 101 |
+
pdf_file,
|
| 102 |
+
gdrive_url: str,
|
| 103 |
+
target_lang: str,
|
| 104 |
+
progress=gr.Progress()
|
| 105 |
+
) -> tuple:
|
| 106 |
+
"""Translate PDF using Modal cloud."""
|
| 107 |
+
logs = []
|
| 108 |
+
|
| 109 |
+
# Validate input
|
| 110 |
+
if not pdf_file and not gdrive_url:
|
| 111 |
+
return None, None, "Please upload a PDF or provide a Google Drive link", "", "\n".join(logs)
|
| 112 |
+
|
| 113 |
+
if pdf_file and gdrive_url:
|
| 114 |
+
return None, None, "Please use either file upload OR Google Drive link, not both", "", "\n".join(logs)
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
logs = log_message(logs, "Starting translation...")
|
| 118 |
+
|
| 119 |
+
# Get PDF bytes and filename
|
| 120 |
+
if gdrive_url:
|
| 121 |
+
logs = log_message(logs, f"Downloading from Google Drive...")
|
| 122 |
+
progress(0.05, desc="Downloading from Google Drive...")
|
| 123 |
+
pdf_bytes, source_filename = await download_gdrive_public(gdrive_url.strip())
|
| 124 |
+
logs = log_message(logs, f"Downloaded: {source_filename}")
|
| 125 |
+
else:
|
| 126 |
+
pdf_path = Path(pdf_file)
|
| 127 |
+
pdf_bytes = pdf_path.read_bytes()
|
| 128 |
+
source_filename = pdf_path.name
|
| 129 |
+
|
| 130 |
+
pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8")
|
| 131 |
+
|
| 132 |
+
logs = log_message(logs, f"Input: {source_filename}")
|
| 133 |
+
logs = log_message(logs, f"Size: {len(pdf_bytes) / 1024:.1f} KB")
|
| 134 |
+
logs = log_message(logs, f"Target: {LANGUAGES.get(target_lang, target_lang)}")
|
| 135 |
+
|
| 136 |
+
progress(0.1, desc="Uploading to Modal...")
|
| 137 |
+
|
| 138 |
+
payload = {
|
| 139 |
+
"pdf_base64": pdf_base64,
|
| 140 |
+
"target_lang": target_lang,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
logs = log_message(logs, "Translating on Modal cloud...")
|
| 144 |
+
logs = log_message(logs, "(This may take several minutes)")
|
| 145 |
+
|
| 146 |
+
progress(0.2, desc="Translating...")
|
| 147 |
+
start_time = datetime.now()
|
| 148 |
+
|
| 149 |
+
async with httpx.AsyncClient(timeout=900.0, follow_redirects=True) as client:
|
| 150 |
+
response = await client.post(MODAL_TRANSLATE_URL, json=payload)
|
| 151 |
+
response.raise_for_status()
|
| 152 |
+
result = response.json()
|
| 153 |
+
|
| 154 |
+
duration = (datetime.now() - start_time).total_seconds()
|
| 155 |
+
progress(0.8, desc="Processing result...")
|
| 156 |
+
|
| 157 |
+
if not result.get("success"):
|
| 158 |
+
error_msg = result.get("message", "Unknown error")
|
| 159 |
+
logs = log_message(logs, f"ERROR: {error_msg}")
|
| 160 |
+
return None, None, "Translation failed", "", "\n".join(logs)
|
| 161 |
+
|
| 162 |
+
# Process mono_img PDF
|
| 163 |
+
mono_img_path = None
|
| 164 |
+
mono_img_base64 = result.get("mono_img_pdf_base64")
|
| 165 |
+
if mono_img_base64:
|
| 166 |
+
mono_img_bytes = base64.b64decode(mono_img_base64)
|
| 167 |
+
stem = Path(source_filename).stem
|
| 168 |
+
mono_img_filename = f"{stem}_translated.{target_lang}.pdf"
|
| 169 |
+
mono_img_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
|
| 170 |
+
mono_img_file.write(mono_img_bytes)
|
| 171 |
+
mono_img_file.close()
|
| 172 |
+
mono_img_path = mono_img_file.name
|
| 173 |
+
logs = log_message(logs, f"Mono: {mono_img_filename} ({len(mono_img_bytes) / 1024:.1f} KB)")
|
| 174 |
+
|
| 175 |
+
# Process dual_img PDF
|
| 176 |
+
dual_img_path = None
|
| 177 |
+
dual_img_base64 = result.get("dual_img_pdf_base64")
|
| 178 |
+
if dual_img_base64:
|
| 179 |
+
dual_img_bytes = base64.b64decode(dual_img_base64)
|
| 180 |
+
stem = Path(source_filename).stem
|
| 181 |
+
dual_img_filename = f"{stem}_translated.{target_lang}.dual.pdf"
|
| 182 |
+
dual_img_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
|
| 183 |
+
dual_img_file.write(dual_img_bytes)
|
| 184 |
+
dual_img_file.close()
|
| 185 |
+
dual_img_path = dual_img_file.name
|
| 186 |
+
logs = log_message(logs, f"Dual: {dual_img_filename} ({len(dual_img_bytes) / 1024:.1f} KB)")
|
| 187 |
+
|
| 188 |
+
if not mono_img_path and not dual_img_path:
|
| 189 |
+
logs = log_message(logs, "ERROR: No output PDF in response")
|
| 190 |
+
return None, None, "Translation failed", "", "\n".join(logs)
|
| 191 |
+
|
| 192 |
+
logs = log_message(logs, f"Duration: {duration:.1f} seconds")
|
| 193 |
+
|
| 194 |
+
stats_msg = f"""**Translation completed!**
|
| 195 |
+
|
| 196 |
+
- **Duration:** {duration:.1f} seconds
|
| 197 |
+
- **Target:** {LANGUAGES.get(target_lang, target_lang)}"""
|
| 198 |
+
|
| 199 |
+
progress(1.0, desc="Done!")
|
| 200 |
+
|
| 201 |
+
return mono_img_path, dual_img_path, "Translation successful!", stats_msg, "\n".join(logs)
|
| 202 |
+
|
| 203 |
+
except httpx.TimeoutException:
|
| 204 |
+
logs = log_message(logs, "ERROR: Translation timed out")
|
| 205 |
+
return None, None, "Translation timed out", "", "\n".join(logs)
|
| 206 |
+
except httpx.HTTPStatusError as e:
|
| 207 |
+
logs = log_message(logs, f"ERROR: HTTP {e.response.status_code}")
|
| 208 |
+
return None, None, f"HTTP error: {e.response.status_code}", "", "\n".join(logs)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logs = log_message(logs, f"ERROR: {str(e)}")
|
| 211 |
+
return None, None, f"Error: {str(e)}", "", "\n".join(logs)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# Gradio Interface
|
| 215 |
+
with gr.Blocks(title="BabelDocs x Agentic AI MCP") as demo:
|
| 216 |
+
|
| 217 |
+
gr.Markdown("""
|
| 218 |
+
# BabelDocs x Agentic AI MCP - PDF Translation with Google Drive Integration
|
| 219 |
+
|
| 220 |
+
**Translate PDFs directly from Google Drive and save back automatically**
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## Key Feature: Full Google Drive Workflow in CLAUDE Desktop MCP
|
| 225 |
+
|
| 226 |
+
```
|
| 227 |
+
"Translate my Q3 report to French and save it to Translations folder"
|
| 228 |
+
↓
|
| 229 |
+
Claude searches → downloads → translates → uploads → done!
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
""")
|
| 234 |
+
|
| 235 |
+
with gr.Row():
|
| 236 |
+
with gr.Column(scale=1):
|
| 237 |
+
gr.Markdown("### Input")
|
| 238 |
+
|
| 239 |
+
gdrive_url = gr.Textbox(
|
| 240 |
+
label="Google Drive Link (public)",
|
| 241 |
+
placeholder="https://drive.google.com/file/d/... or leave empty",
|
| 242 |
+
info="Paste a public GDrive link, OR upload a local file below",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
gr.Markdown("**OR**")
|
| 246 |
+
|
| 247 |
+
pdf_input = gr.File(
|
| 248 |
+
label="Upload PDF",
|
| 249 |
+
file_types=[".pdf"],
|
| 250 |
+
type="filepath",
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
target_lang = gr.Dropdown(
|
| 254 |
+
choices=list(LANGUAGES.keys()),
|
| 255 |
+
value="fr",
|
| 256 |
+
label="Target Language",
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
translate_btn = gr.Button(
|
| 260 |
+
"Translate PDF",
|
| 261 |
+
variant="primary",
|
| 262 |
+
size="lg",
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
with gr.Column(scale=1):
|
| 266 |
+
gr.Markdown("### Result")
|
| 267 |
+
|
| 268 |
+
status_output = gr.Textbox(
|
| 269 |
+
label="Status",
|
| 270 |
+
interactive=False,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
stats_output = gr.Markdown(label="Statistics")
|
| 274 |
+
|
| 275 |
+
gr.Markdown("**Downloads:**")
|
| 276 |
+
with gr.Row():
|
| 277 |
+
mono_img_output = gr.File(label="Mono (translated + images)")
|
| 278 |
+
dual_img_output = gr.File(label="Dual (bilingual + images)")
|
| 279 |
+
|
| 280 |
+
logs_output = gr.Textbox(
|
| 281 |
+
label="Logs",
|
| 282 |
+
interactive=False,
|
| 283 |
+
lines=10,
|
| 284 |
+
max_lines=15,
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
gr.Markdown("""
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
### How it works
|
| 291 |
+
|
| 292 |
+
```
|
| 293 |
+
1. Upload PDF or paste GDrive link
|
| 294 |
+
↓
|
| 295 |
+
2. Send to Modal cloud (serverless)
|
| 296 |
+
↓
|
| 297 |
+
3. BabelDOC with Agentic AI translates text + images, preserves layout
|
| 298 |
+
↓
|
| 299 |
+
4. Download translated PDF
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
**Built with:** BabelDOC, Modal, Nebius AI, Gradio | **Hackathon:** Anthropic MCP Track 1
|
| 305 |
+
""")
|
| 306 |
+
|
| 307 |
+
translate_btn.click(
|
| 308 |
+
fn=translate_pdf_modal,
|
| 309 |
+
inputs=[pdf_input, gdrive_url, target_lang],
|
| 310 |
+
outputs=[mono_img_output, dual_img_output, status_output, stats_output, logs_output],
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
if __name__ == "__main__":
|
| 315 |
+
demo.launch(
|
| 316 |
+
server_name="127.0.0.1",
|
| 317 |
+
server_port=7860,
|
| 318 |
+
share=False,
|
| 319 |
+
)
|
modal_deploy.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BabelDOC with Agentic AI - Modal Deployment
|
| 3 |
+
|
| 4 |
+
PDF translation API with layout preservation.
|
| 5 |
+
20-page limit during test phase.
|
| 6 |
+
|
| 7 |
+
Setup:
|
| 8 |
+
modal secret create babeldocs-secrets \
|
| 9 |
+
NEBIUS_API_KEY=your_key \
|
| 10 |
+
NEBIUS_API_BASE=https://api.tokenfactory.nebius.com/v1/ \
|
| 11 |
+
NEBIUS_TRANSLATION_MODEL=openai/gpt-oss-120b
|
| 12 |
+
|
| 13 |
+
Deploy:
|
| 14 |
+
modal deploy modal_deploy.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import modal
|
| 18 |
+
import os
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
THIS_DIR = Path(__file__).parent.resolve()
|
| 22 |
+
BABELDOC_DIR = THIS_DIR.parent / "BabelDOC"
|
| 23 |
+
|
| 24 |
+
# Max pages allowed (test phase limit)
|
| 25 |
+
MAX_PAGES = 20
|
| 26 |
+
|
| 27 |
+
# Modal app - custom name for hackathon
|
| 28 |
+
app = modal.App("mcp1stann-babeldocs")
|
| 29 |
+
|
| 30 |
+
# Image with uv and BabelDOC installed
|
| 31 |
+
babeldocs_image = (
|
| 32 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 33 |
+
.apt_install(
|
| 34 |
+
"git",
|
| 35 |
+
"libgl1-mesa-glx",
|
| 36 |
+
"libglib2.0-0",
|
| 37 |
+
"libsm6",
|
| 38 |
+
"libxext6",
|
| 39 |
+
"libxrender-dev",
|
| 40 |
+
"libgomp1",
|
| 41 |
+
"curl",
|
| 42 |
+
"libspatialindex-dev", # For rtree
|
| 43 |
+
"libharfbuzz-dev", # For uharfbuzz
|
| 44 |
+
"libfreetype6-dev", # For freetype-py
|
| 45 |
+
"libopencv-dev", # For opencv dependencies
|
| 46 |
+
"libzstd-dev", # For pyzstd
|
| 47 |
+
)
|
| 48 |
+
.pip_install("uv")
|
| 49 |
+
.env({
|
| 50 |
+
"PYTHONIOENCODING": "utf-8",
|
| 51 |
+
"PYTHONUNBUFFERED": "1",
|
| 52 |
+
"UV_SYSTEM_PYTHON": "1",
|
| 53 |
+
})
|
| 54 |
+
.pip_install("fastapi[standard]")
|
| 55 |
+
.add_local_dir(
|
| 56 |
+
str(BABELDOC_DIR),
|
| 57 |
+
remote_path="/app/BabelDOC",
|
| 58 |
+
copy=True,
|
| 59 |
+
)
|
| 60 |
+
.run_commands(
|
| 61 |
+
"cd /app/BabelDOC && uv pip install -e . --python python3.11",
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Volume for caching models and fonts
|
| 66 |
+
cache_volume = modal.Volume.from_name("babeldocs-cache", create_if_missing=True)
|
| 67 |
+
CACHE_PATH = "/cache"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@app.cls(
|
| 71 |
+
image=babeldocs_image,
|
| 72 |
+
timeout=900, # 15 minutes
|
| 73 |
+
memory=8192,
|
| 74 |
+
cpu=4,
|
| 75 |
+
volumes={CACHE_PATH: cache_volume},
|
| 76 |
+
secrets=[modal.Secret.from_name("babeldocs-secrets")],
|
| 77 |
+
scaledown_window=300, # Keep warm for 5 minutes
|
| 78 |
+
)
|
| 79 |
+
class BabelDocsTranslator:
|
| 80 |
+
"""Class-based translator for BabelDOC (based on working SVG generator pattern)."""
|
| 81 |
+
|
| 82 |
+
def _count_pdf_pages(self, pdf_bytes: bytes) -> int:
|
| 83 |
+
"""Count pages in PDF using PyMuPDF."""
|
| 84 |
+
try:
|
| 85 |
+
import fitz # PyMuPDF
|
| 86 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 87 |
+
count = len(doc)
|
| 88 |
+
doc.close()
|
| 89 |
+
return count
|
| 90 |
+
except Exception:
|
| 91 |
+
return -1 # Unknown
|
| 92 |
+
|
| 93 |
+
def _translate_internal(
|
| 94 |
+
self,
|
| 95 |
+
pdf_base64: str,
|
| 96 |
+
target_lang: str = "fr",
|
| 97 |
+
pages: str = "",
|
| 98 |
+
no_dual: bool = False,
|
| 99 |
+
no_mono: bool = False,
|
| 100 |
+
) -> dict:
|
| 101 |
+
"""BabelDOC with Agentic AI - Internal translation."""
|
| 102 |
+
import base64
|
| 103 |
+
import subprocess
|
| 104 |
+
import tempfile
|
| 105 |
+
from pathlib import Path
|
| 106 |
+
from datetime import datetime
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
if not pdf_base64:
|
| 110 |
+
return {"success": False, "message": "No PDF provided"}
|
| 111 |
+
|
| 112 |
+
pdf_bytes = base64.b64decode(pdf_base64)
|
| 113 |
+
|
| 114 |
+
# Check page limit (test phase)
|
| 115 |
+
page_count = self._count_pdf_pages(pdf_bytes)
|
| 116 |
+
if page_count > MAX_PAGES:
|
| 117 |
+
return {
|
| 118 |
+
"success": False,
|
| 119 |
+
"message": f"PDF has {page_count} pages. Maximum allowed: {MAX_PAGES} pages (test phase limit)."
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 123 |
+
input_path = Path(tmpdir) / "input.pdf"
|
| 124 |
+
output_dir = Path(tmpdir) / "output"
|
| 125 |
+
output_dir.mkdir()
|
| 126 |
+
|
| 127 |
+
input_path.write_bytes(pdf_bytes)
|
| 128 |
+
|
| 129 |
+
cmd = [
|
| 130 |
+
"babeldoc",
|
| 131 |
+
"--files", str(input_path),
|
| 132 |
+
"--output", str(output_dir),
|
| 133 |
+
"--lang-out", target_lang,
|
| 134 |
+
"--openai",
|
| 135 |
+
"--openai-model", os.getenv("NEBIUS_TRANSLATION_MODEL", "openai/gpt-oss-120b"),
|
| 136 |
+
"--openai-base-url", os.getenv("NEBIUS_API_BASE", "https://api.tokenfactory.nebius.com/v1/"),
|
| 137 |
+
"--openai-api-key", os.getenv("NEBIUS_API_KEY", ""),
|
| 138 |
+
"--no-watermark",
|
| 139 |
+
"--translate-table-text",
|
| 140 |
+
"--enhance-compatibility",
|
| 141 |
+
# Enable image translation (orchestration PASS 2) with vision model
|
| 142 |
+
"--vision-model", os.getenv("NEBIUS_VISION_MODEL", "Qwen/Qwen2.5-VL-72B-Instruct"),
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
if pages:
|
| 146 |
+
cmd.extend(["--pages", pages])
|
| 147 |
+
cmd.append("--only-include-translated-page")
|
| 148 |
+
|
| 149 |
+
if no_dual:
|
| 150 |
+
cmd.append("--no-dual")
|
| 151 |
+
|
| 152 |
+
if no_mono:
|
| 153 |
+
cmd.append("--no-mono")
|
| 154 |
+
|
| 155 |
+
start_time = datetime.now()
|
| 156 |
+
|
| 157 |
+
result = subprocess.run(
|
| 158 |
+
cmd,
|
| 159 |
+
capture_output=True,
|
| 160 |
+
text=True,
|
| 161 |
+
encoding="utf-8",
|
| 162 |
+
errors="replace",
|
| 163 |
+
cwd="/app/BabelDOC",
|
| 164 |
+
env={
|
| 165 |
+
**os.environ,
|
| 166 |
+
"HF_HOME": CACHE_PATH,
|
| 167 |
+
},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
duration = (datetime.now() - start_time).total_seconds()
|
| 171 |
+
|
| 172 |
+
if result.returncode != 0:
|
| 173 |
+
return {
|
| 174 |
+
"success": False,
|
| 175 |
+
"message": "Translation failed",
|
| 176 |
+
"stderr": result.stderr[:1000] if result.stderr else "",
|
| 177 |
+
"stdout": result.stdout[:500] if result.stdout else "",
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# Find all 4 types of PDFs:
|
| 181 |
+
# Format: name.no_watermark.{lang}.{mono|dual}.pdf
|
| 182 |
+
# Format: name.no_watermark.{lang}.{mono|dual}.images_translated.pdf
|
| 183 |
+
|
| 184 |
+
# Get all PDFs in output directory
|
| 185 |
+
all_pdfs = list(output_dir.glob("*.pdf"))
|
| 186 |
+
|
| 187 |
+
# Categorize by type
|
| 188 |
+
mono_matches = [p for p in all_pdfs if f".{target_lang}.mono.pdf" in p.name and "images_translated" not in p.name]
|
| 189 |
+
mono_img_matches = [p for p in all_pdfs if f".{target_lang}.mono.images_translated.pdf" in p.name]
|
| 190 |
+
dual_matches = [p for p in all_pdfs if f".{target_lang}.dual.pdf" in p.name and "images_translated" not in p.name]
|
| 191 |
+
dual_img_matches = [p for p in all_pdfs if f".{target_lang}.dual.images_translated.pdf" in p.name]
|
| 192 |
+
|
| 193 |
+
mono_pdf = mono_matches[0] if mono_matches else None
|
| 194 |
+
mono_img_pdf = mono_img_matches[0] if mono_img_matches else None
|
| 195 |
+
dual_pdf = dual_matches[0] if dual_matches else None
|
| 196 |
+
dual_img_pdf = dual_img_matches[0] if dual_img_matches else None
|
| 197 |
+
|
| 198 |
+
if not any([mono_pdf, mono_img_pdf, dual_pdf, dual_img_pdf]):
|
| 199 |
+
# Fallback to any PDF
|
| 200 |
+
if not all_pdfs:
|
| 201 |
+
return {"success": False, "message": "No output PDF generated"}
|
| 202 |
+
mono_pdf = all_pdfs[0]
|
| 203 |
+
|
| 204 |
+
result_data = {
|
| 205 |
+
"success": True,
|
| 206 |
+
"stats": {
|
| 207 |
+
"duration_seconds": round(duration, 2),
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
# Add mono PDF (without image translation)
|
| 212 |
+
if mono_pdf and not no_mono:
|
| 213 |
+
mono_bytes = mono_pdf.read_bytes()
|
| 214 |
+
result_data["mono_pdf_base64"] = base64.b64encode(mono_bytes).decode("utf-8")
|
| 215 |
+
result_data["mono_filename"] = mono_pdf.name
|
| 216 |
+
result_data["stats"]["mono_size_bytes"] = len(mono_bytes)
|
| 217 |
+
|
| 218 |
+
# Add mono PDF with image translation
|
| 219 |
+
if mono_img_pdf and not no_mono:
|
| 220 |
+
mono_img_bytes = mono_img_pdf.read_bytes()
|
| 221 |
+
result_data["mono_img_pdf_base64"] = base64.b64encode(mono_img_bytes).decode("utf-8")
|
| 222 |
+
result_data["mono_img_filename"] = mono_img_pdf.name
|
| 223 |
+
result_data["stats"]["mono_img_size_bytes"] = len(mono_img_bytes)
|
| 224 |
+
|
| 225 |
+
# Add dual PDF (without image translation)
|
| 226 |
+
if dual_pdf and not no_dual:
|
| 227 |
+
dual_bytes = dual_pdf.read_bytes()
|
| 228 |
+
result_data["dual_pdf_base64"] = base64.b64encode(dual_bytes).decode("utf-8")
|
| 229 |
+
result_data["dual_filename"] = dual_pdf.name
|
| 230 |
+
result_data["stats"]["dual_size_bytes"] = len(dual_bytes)
|
| 231 |
+
|
| 232 |
+
# Add dual PDF with image translation
|
| 233 |
+
if dual_img_pdf and not no_dual:
|
| 234 |
+
dual_img_bytes = dual_img_pdf.read_bytes()
|
| 235 |
+
result_data["dual_img_pdf_base64"] = base64.b64encode(dual_img_bytes).decode("utf-8")
|
| 236 |
+
result_data["dual_img_filename"] = dual_img_pdf.name
|
| 237 |
+
result_data["stats"]["dual_img_size_bytes"] = len(dual_img_bytes)
|
| 238 |
+
|
| 239 |
+
return result_data
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
return {"success": False, "message": f"Error: {str(e)}"}
|
| 243 |
+
|
| 244 |
+
@modal.method()
|
| 245 |
+
def translate(
|
| 246 |
+
self,
|
| 247 |
+
pdf_base64: str,
|
| 248 |
+
target_lang: str = "fr",
|
| 249 |
+
pages: str = "",
|
| 250 |
+
no_dual: bool = False,
|
| 251 |
+
no_mono: bool = False,
|
| 252 |
+
) -> dict:
|
| 253 |
+
"""Translate method (callable via Modal)."""
|
| 254 |
+
return self._translate_internal(pdf_base64, target_lang, pages, no_dual, no_mono)
|
| 255 |
+
|
| 256 |
+
@modal.fastapi_endpoint(method="POST")
|
| 257 |
+
def api(self, request: dict) -> dict:
|
| 258 |
+
"""
|
| 259 |
+
FastAPI endpoint POST for PDF translation.
|
| 260 |
+
|
| 261 |
+
Request body:
|
| 262 |
+
{
|
| 263 |
+
"pdf_base64": "base64_encoded_pdf",
|
| 264 |
+
"target_lang": "fr",
|
| 265 |
+
"pages": "1,2,3" (optional),
|
| 266 |
+
"no_dual": false,
|
| 267 |
+
"no_mono": false
|
| 268 |
+
}
|
| 269 |
+
"""
|
| 270 |
+
pdf_base64 = request.get("pdf_base64", "")
|
| 271 |
+
target_lang = request.get("target_lang", "fr")
|
| 272 |
+
pages = request.get("pages", "")
|
| 273 |
+
no_dual = request.get("no_dual", False)
|
| 274 |
+
no_mono = request.get("no_mono", False)
|
| 275 |
+
|
| 276 |
+
return self._translate_internal(pdf_base64, target_lang, pages, no_dual, no_mono)
|
| 277 |
+
|
| 278 |
+
@modal.fastapi_endpoint(method="GET")
|
| 279 |
+
def health(self) -> dict:
|
| 280 |
+
"""Health check endpoint."""
|
| 281 |
+
return {
|
| 282 |
+
"status": "healthy",
|
| 283 |
+
"service": "BabelDOC with Agentic AI",
|
| 284 |
+
"version": "1.0.0",
|
| 285 |
+
"max_pages": MAX_PAGES,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
@modal.fastapi_endpoint(method="GET")
|
| 289 |
+
def languages(self) -> dict:
|
| 290 |
+
"""Get supported languages."""
|
| 291 |
+
return {
|
| 292 |
+
"languages": {
|
| 293 |
+
"fr": "French",
|
| 294 |
+
"en": "English",
|
| 295 |
+
"es": "Spanish",
|
| 296 |
+
"de": "German",
|
| 297 |
+
"it": "Italian",
|
| 298 |
+
"pt": "Portuguese",
|
| 299 |
+
"zh": "Chinese",
|
| 300 |
+
"ja": "Japanese",
|
| 301 |
+
"ko": "Korean",
|
| 302 |
+
"ru": "Russian",
|
| 303 |
+
"ar": "Arabic",
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@app.local_entrypoint()
|
| 309 |
+
def main():
|
| 310 |
+
"""BabelDOC with Agentic AI - Local test."""
|
| 311 |
+
print("BabelDOC with Agentic AI - Modal Deployment")
|
| 312 |
+
print("=" * 45)
|
| 313 |
+
print(f"Max pages: {MAX_PAGES} (test phase)")
|
| 314 |
+
print()
|
| 315 |
+
print("Deploy: modal deploy modal_deploy.py")
|
| 316 |
+
print("Test: modal serve modal_deploy.py")
|
requirements.txt
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BabelDocs MCP Server Dependencies
|
| 2 |
+
|
| 3 |
+
# MCP Framework
|
| 4 |
+
fastmcp>=0.1.0
|
| 5 |
+
|
| 6 |
+
# Web Framework
|
| 7 |
+
gradio>=4.44.0
|
| 8 |
+
uvicorn>=0.30.0
|
| 9 |
+
|
| 10 |
+
# PDF Processing
|
| 11 |
+
pymupdf>=1.24.0
|
| 12 |
+
|
| 13 |
+
# HTTP Client
|
| 14 |
+
httpx>=0.25.0
|
| 15 |
+
|
| 16 |
+
# Environment
|
| 17 |
+
python-dotenv>=1.0.0
|
| 18 |
+
|
| 19 |
+
# AI APIs
|
| 20 |
+
anthropic>=0.39.0
|
| 21 |
+
openai>=1.0.0
|
| 22 |
+
|
| 23 |
+
# Modal deployment
|
| 24 |
+
modal>=0.64.0
|
server.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BabelDOC with Agentic AI - MCP Server
|
| 3 |
+
PDF Translation with Layout Preservation + Google Drive Integration
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import json
|
| 9 |
+
import base64
|
| 10 |
+
import httpx
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional, Tuple, List
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from fastmcp import FastMCP
|
| 16 |
+
|
| 17 |
+
# Google Drive OAuth
|
| 18 |
+
try:
|
| 19 |
+
from google.oauth2.credentials import Credentials
|
| 20 |
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
| 21 |
+
from google.auth.transport.requests import Request
|
| 22 |
+
from googleapiclient.discovery import build
|
| 23 |
+
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
|
| 24 |
+
import io
|
| 25 |
+
GOOGLE_AVAILABLE = True
|
| 26 |
+
except ImportError:
|
| 27 |
+
GOOGLE_AVAILABLE = False
|
| 28 |
+
|
| 29 |
+
# Constants
|
| 30 |
+
MAX_PAGES = 20 # Test phase limit
|
| 31 |
+
GRADIO_URL = "http://127.0.0.1:7860"
|
| 32 |
+
OUTPUT_DIR = Path.home() / "Downloads" / "BabelDocs"
|
| 33 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
# Google Drive OAuth config
|
| 36 |
+
GDRIVE_SCOPES = ['https://www.googleapis.com/auth/drive']
|
| 37 |
+
GDRIVE_OAUTH_PATH = Path(os.getenv(
|
| 38 |
+
"GDRIVE_OAUTH_CREDENTIALS",
|
| 39 |
+
Path.home() / "Downloads" / "gcp-oauth.keys.json"
|
| 40 |
+
))
|
| 41 |
+
GDRIVE_TOKEN_PATH = OUTPUT_DIR / "gdrive_token.json"
|
| 42 |
+
|
| 43 |
+
# Modal endpoints (set BABELDOCS_MODAL_URL env var)
|
| 44 |
+
MODAL_BASE_URL = os.getenv("BABELDOCS_MODAL_URL")
|
| 45 |
+
if not MODAL_BASE_URL:
|
| 46 |
+
raise ValueError("BABELDOCS_MODAL_URL environment variable is required")
|
| 47 |
+
MODAL_TRANSLATE_URL = f"{MODAL_BASE_URL}-babeldocstranslator-api.modal.run"
|
| 48 |
+
MODAL_HEALTH_URL = f"{MODAL_BASE_URL}-babeldocstranslator-health.modal.run"
|
| 49 |
+
|
| 50 |
+
SUPPORTED_LANGUAGES = {
|
| 51 |
+
"en": "English", "fr": "French", "es": "Spanish", "de": "German",
|
| 52 |
+
"it": "Italian", "pt": "Portuguese", "zh": "Chinese", "ja": "Japanese",
|
| 53 |
+
"ko": "Korean", "ru": "Russian", "ar": "Arabic",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# === Helper Functions ===
|
| 58 |
+
|
| 59 |
+
async def _warmup_modal():
|
| 60 |
+
"""Wake up Modal container."""
|
| 61 |
+
try:
|
| 62 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 63 |
+
await client.get(MODAL_HEALTH_URL)
|
| 64 |
+
except:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _count_pdf_pages(pdf_bytes: bytes) -> int:
|
| 69 |
+
"""Count pages in PDF."""
|
| 70 |
+
try:
|
| 71 |
+
import fitz
|
| 72 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 73 |
+
count = len(doc)
|
| 74 |
+
doc.close()
|
| 75 |
+
return count
|
| 76 |
+
except ImportError:
|
| 77 |
+
content = pdf_bytes.decode('latin-1', errors='ignore')
|
| 78 |
+
return content.count('/Type /Page') - content.count('/Type /Pages')
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _extract_gdrive_file_id(url: str) -> Optional[str]:
|
| 82 |
+
"""Extract file ID from Google Drive URL."""
|
| 83 |
+
patterns = [
|
| 84 |
+
r'/file/d/([a-zA-Z0-9_-]+)',
|
| 85 |
+
r'id=([a-zA-Z0-9_-]+)',
|
| 86 |
+
r'/open\?id=([a-zA-Z0-9_-]+)',
|
| 87 |
+
r'^([a-zA-Z0-9_-]{25,})$',
|
| 88 |
+
]
|
| 89 |
+
for pattern in patterns:
|
| 90 |
+
match = re.search(pattern, url)
|
| 91 |
+
if match:
|
| 92 |
+
return match.group(1)
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _get_gdrive_credentials():
|
| 97 |
+
"""Get or refresh Google Drive credentials."""
|
| 98 |
+
if not GOOGLE_AVAILABLE:
|
| 99 |
+
return None, "Google libraries not installed"
|
| 100 |
+
if not GDRIVE_OAUTH_PATH.exists():
|
| 101 |
+
return None, f"OAuth credentials not found at {GDRIVE_OAUTH_PATH}"
|
| 102 |
+
|
| 103 |
+
creds = None
|
| 104 |
+
if GDRIVE_TOKEN_PATH.exists():
|
| 105 |
+
try:
|
| 106 |
+
creds = Credentials.from_authorized_user_file(str(GDRIVE_TOKEN_PATH), GDRIVE_SCOPES)
|
| 107 |
+
except:
|
| 108 |
+
pass
|
| 109 |
+
|
| 110 |
+
if not creds or not creds.valid:
|
| 111 |
+
if creds and creds.expired and creds.refresh_token:
|
| 112 |
+
try:
|
| 113 |
+
creds.refresh(Request())
|
| 114 |
+
except:
|
| 115 |
+
creds = None
|
| 116 |
+
|
| 117 |
+
if not creds:
|
| 118 |
+
try:
|
| 119 |
+
flow = InstalledAppFlow.from_client_secrets_file(str(GDRIVE_OAUTH_PATH), GDRIVE_SCOPES)
|
| 120 |
+
for port in [8101, 8102, 8103, 0]:
|
| 121 |
+
try:
|
| 122 |
+
creds = flow.run_local_server(port=port, open_browser=True, bind_addr="127.0.0.1")
|
| 123 |
+
break
|
| 124 |
+
except OSError:
|
| 125 |
+
if port == 0:
|
| 126 |
+
raise
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return None, f"OAuth failed: {str(e)}"
|
| 129 |
+
|
| 130 |
+
with open(GDRIVE_TOKEN_PATH, 'w') as token:
|
| 131 |
+
token.write(creds.to_json())
|
| 132 |
+
|
| 133 |
+
return creds, None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _upload_to_gdrive(file_path: str, folder_id: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
|
| 137 |
+
"""Upload file to Google Drive. Returns (file_id, error)."""
|
| 138 |
+
creds, error = _get_gdrive_credentials()
|
| 139 |
+
if error:
|
| 140 |
+
return None, error
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
service = build('drive', 'v3', credentials=creds)
|
| 144 |
+
file_metadata = {'name': Path(file_path).name}
|
| 145 |
+
if folder_id:
|
| 146 |
+
file_metadata['parents'] = [folder_id]
|
| 147 |
+
|
| 148 |
+
media = MediaFileUpload(file_path, mimetype='application/pdf', resumable=True)
|
| 149 |
+
file = service.files().create(body=file_metadata, media_body=media, fields='id, webViewLink').execute()
|
| 150 |
+
return file.get('id'), None
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return None, f"Upload failed: {str(e)}"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _list_gdrive_folders() -> Tuple[Optional[List[dict]], Optional[str]]:
|
| 156 |
+
"""List folders in Google Drive."""
|
| 157 |
+
creds, error = _get_gdrive_credentials()
|
| 158 |
+
if error:
|
| 159 |
+
return None, error
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
service = build('drive', 'v3', credentials=creds)
|
| 163 |
+
results = service.files().list(
|
| 164 |
+
q="mimeType='application/vnd.google-apps.folder' and trashed=false",
|
| 165 |
+
fields='files(id, name)', pageSize=50
|
| 166 |
+
).execute()
|
| 167 |
+
return results.get('files', []), None
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return None, f"Failed to list folders: {str(e)}"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _list_gdrive_files(folder_id: Optional[str] = None, file_type: Optional[str] = None) -> Tuple[Optional[List[dict]], Optional[str]]:
|
| 173 |
+
"""List files in Google Drive."""
|
| 174 |
+
creds, error = _get_gdrive_credentials()
|
| 175 |
+
if error:
|
| 176 |
+
return None, error
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
service = build('drive', 'v3', credentials=creds)
|
| 180 |
+
query_parts = ["trashed=false"]
|
| 181 |
+
if folder_id:
|
| 182 |
+
query_parts.append(f"'{folder_id}' in parents")
|
| 183 |
+
if file_type == "pdf":
|
| 184 |
+
query_parts.append("mimeType='application/pdf'")
|
| 185 |
+
elif file_type == "folder":
|
| 186 |
+
query_parts.append("mimeType='application/vnd.google-apps.folder'")
|
| 187 |
+
|
| 188 |
+
results = service.files().list(
|
| 189 |
+
q=" and ".join(query_parts),
|
| 190 |
+
fields='files(id, name, mimeType, size, webViewLink)',
|
| 191 |
+
pageSize=100, orderBy='modifiedTime desc'
|
| 192 |
+
).execute()
|
| 193 |
+
return results.get('files', []), None
|
| 194 |
+
except Exception as e:
|
| 195 |
+
return None, f"Failed to list files: {str(e)}"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _search_gdrive_files(query: str, file_type: Optional[str] = None) -> Tuple[Optional[List[dict]], Optional[str]]:
|
| 199 |
+
"""Search files in Google Drive by name."""
|
| 200 |
+
creds, error = _get_gdrive_credentials()
|
| 201 |
+
if error:
|
| 202 |
+
return None, error
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
service = build('drive', 'v3', credentials=creds)
|
| 206 |
+
query_parts = [f"name contains '{query}'", "trashed=false"]
|
| 207 |
+
if file_type == "pdf":
|
| 208 |
+
query_parts.append("mimeType='application/pdf'")
|
| 209 |
+
|
| 210 |
+
results = service.files().list(
|
| 211 |
+
q=" and ".join(query_parts),
|
| 212 |
+
fields='files(id, name, mimeType, size, webViewLink)',
|
| 213 |
+
pageSize=50, orderBy='modifiedTime desc'
|
| 214 |
+
).execute()
|
| 215 |
+
return results.get('files', []), None
|
| 216 |
+
except Exception as e:
|
| 217 |
+
return None, f"Search failed: {str(e)}"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _download_gdrive_file(file_id: str, destination: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
|
| 221 |
+
"""Download file from Google Drive."""
|
| 222 |
+
creds, error = _get_gdrive_credentials()
|
| 223 |
+
if error:
|
| 224 |
+
return None, error
|
| 225 |
+
|
| 226 |
+
try:
|
| 227 |
+
service = build('drive', 'v3', credentials=creds)
|
| 228 |
+
file_metadata = service.files().get(fileId=file_id, fields='name').execute()
|
| 229 |
+
filename = file_metadata.get('name', f'download_{file_id}')
|
| 230 |
+
|
| 231 |
+
dest_path = Path(destination) / filename if destination else OUTPUT_DIR / filename
|
| 232 |
+
|
| 233 |
+
request = service.files().get_media(fileId=file_id)
|
| 234 |
+
file_handle = io.BytesIO()
|
| 235 |
+
downloader = MediaIoBaseDownload(file_handle, request)
|
| 236 |
+
done = False
|
| 237 |
+
while not done:
|
| 238 |
+
_, done = downloader.next_chunk()
|
| 239 |
+
|
| 240 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 241 |
+
dest_path.write_bytes(file_handle.getvalue())
|
| 242 |
+
return str(dest_path), None
|
| 243 |
+
except Exception as e:
|
| 244 |
+
return None, f"Download failed: {str(e)}"
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def _get_pdf_bytes(source: str) -> Tuple[bytes, str, Optional[str]]:
|
| 248 |
+
"""Get PDF bytes from local file or Google Drive URL. Returns (bytes, source_name, error)."""
|
| 249 |
+
# Google Drive URL
|
| 250 |
+
if "drive.google.com" in source or "docs.google.com" in source:
|
| 251 |
+
file_id = _extract_gdrive_file_id(source)
|
| 252 |
+
if not file_id:
|
| 253 |
+
return b"", "", "Invalid Google Drive URL"
|
| 254 |
+
|
| 255 |
+
local_path, error = _download_gdrive_file(file_id)
|
| 256 |
+
if error:
|
| 257 |
+
return b"", "", error
|
| 258 |
+
|
| 259 |
+
pdf_bytes = Path(local_path).read_bytes()
|
| 260 |
+
return pdf_bytes, f"Google Drive: {Path(local_path).name}", None
|
| 261 |
+
|
| 262 |
+
# Just file ID
|
| 263 |
+
if re.match(r'^[a-zA-Z0-9_-]{25,}$', source):
|
| 264 |
+
local_path, error = _download_gdrive_file(source)
|
| 265 |
+
if error:
|
| 266 |
+
return b"", "", error
|
| 267 |
+
|
| 268 |
+
pdf_bytes = Path(local_path).read_bytes()
|
| 269 |
+
return pdf_bytes, f"Google Drive: {Path(local_path).name}", None
|
| 270 |
+
|
| 271 |
+
# Local file
|
| 272 |
+
pdf_path = Path(source)
|
| 273 |
+
if not pdf_path.exists():
|
| 274 |
+
return b"", "", f"File not found: {source}"
|
| 275 |
+
if pdf_path.suffix.lower() != ".pdf":
|
| 276 |
+
return b"", "", "File must be a PDF"
|
| 277 |
+
|
| 278 |
+
return pdf_path.read_bytes(), pdf_path.name, None
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# === MCP Server ===
|
| 282 |
+
|
| 283 |
+
mcp = FastMCP(
|
| 284 |
+
name="babeldocs",
|
| 285 |
+
instructions=f"""PDF translation with layout preservation + Google Drive integration.
|
| 286 |
+
|
| 287 |
+
Max {MAX_PAGES} pages. For larger PDFs use Gradio at {GRADIO_URL}
|
| 288 |
+
|
| 289 |
+
WORKFLOW:
|
| 290 |
+
1. search_gdrive("filename") - Find PDF
|
| 291 |
+
2. download_from_gdrive(file_id) - Download
|
| 292 |
+
3. translate_pdf(path, "fr") - Translate
|
| 293 |
+
4. upload_to_gdrive(path, folder_id) - Upload
|
| 294 |
+
|
| 295 |
+
Or all-in-one: translate_and_upload(source, "fr", folder_id)
|
| 296 |
+
|
| 297 |
+
Output: {OUTPUT_DIR}
|
| 298 |
+
"""
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@mcp.tool()
|
| 303 |
+
async def translate_pdf(source: str, target_lang: str = "fr") -> dict:
|
| 304 |
+
"""Translate PDF with layout preservation. Returns single translated file."""
|
| 305 |
+
await _warmup_modal()
|
| 306 |
+
|
| 307 |
+
try:
|
| 308 |
+
pdf_bytes, source_name, error = await _get_pdf_bytes(source)
|
| 309 |
+
if error:
|
| 310 |
+
return {"success": False, "message": error}
|
| 311 |
+
|
| 312 |
+
page_count = _count_pdf_pages(pdf_bytes)
|
| 313 |
+
if page_count > MAX_PAGES:
|
| 314 |
+
return {
|
| 315 |
+
"success": False,
|
| 316 |
+
"message": f"PDF has {page_count} pages (max {MAX_PAGES}). Use Gradio: {GRADIO_URL}"
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
if target_lang not in SUPPORTED_LANGUAGES:
|
| 320 |
+
return {"success": False, "message": f"Unsupported language: {target_lang}"}
|
| 321 |
+
|
| 322 |
+
# Call Modal
|
| 323 |
+
payload = {
|
| 324 |
+
"pdf_base64": base64.b64encode(pdf_bytes).decode("utf-8"),
|
| 325 |
+
"target_lang": target_lang,
|
| 326 |
+
"no_dual": True,
|
| 327 |
+
"no_mono": False,
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
async with httpx.AsyncClient(timeout=900.0, follow_redirects=True) as client:
|
| 331 |
+
response = await client.post(MODAL_TRANSLATE_URL, json=payload)
|
| 332 |
+
response.raise_for_status()
|
| 333 |
+
result = response.json()
|
| 334 |
+
|
| 335 |
+
if not result.get("success"):
|
| 336 |
+
return {"success": False, "message": result.get("message", "Translation failed")}
|
| 337 |
+
|
| 338 |
+
# Get mono_img (priority) or mono
|
| 339 |
+
pdf_data = result.get("mono_img_pdf_base64") or result.get("mono_pdf_base64")
|
| 340 |
+
if not pdf_data:
|
| 341 |
+
return {"success": False, "message": "No output PDF generated"}
|
| 342 |
+
|
| 343 |
+
# Build output filename
|
| 344 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 345 |
+
if source_name.startswith("Google Drive:"):
|
| 346 |
+
output_filename = f"translated_{timestamp}.{target_lang}.pdf"
|
| 347 |
+
else:
|
| 348 |
+
original_name = Path(source_name).stem
|
| 349 |
+
output_filename = f"{original_name}_translated.{target_lang}.pdf"
|
| 350 |
+
|
| 351 |
+
output_path = OUTPUT_DIR / output_filename
|
| 352 |
+
output_path.write_bytes(base64.b64decode(pdf_data))
|
| 353 |
+
|
| 354 |
+
return {
|
| 355 |
+
"success": True,
|
| 356 |
+
"message": f"Translated to {SUPPORTED_LANGUAGES[target_lang]}",
|
| 357 |
+
"source": source_name,
|
| 358 |
+
"page_count": page_count,
|
| 359 |
+
"output_file": str(output_path),
|
| 360 |
+
"filename": output_filename,
|
| 361 |
+
"stats": result.get("stats", {}),
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
except httpx.TimeoutException:
|
| 365 |
+
return {"success": False, "message": "Translation timed out (max 15 min)"}
|
| 366 |
+
except Exception as e:
|
| 367 |
+
return {"success": False, "message": f"Error: {str(e)}"}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@mcp.tool()
|
| 371 |
+
async def translate_and_upload(source: str, target_lang: str = "fr", folder_id: Optional[str] = None) -> dict:
|
| 372 |
+
"""Translate PDF and upload to Google Drive."""
|
| 373 |
+
result = await translate_pdf(source, target_lang)
|
| 374 |
+
if not result.get("success"):
|
| 375 |
+
return result
|
| 376 |
+
|
| 377 |
+
file_id, error = _upload_to_gdrive(result["output_file"], folder_id)
|
| 378 |
+
if error:
|
| 379 |
+
return {"success": False, "message": error, "local_file": result["output_file"]}
|
| 380 |
+
|
| 381 |
+
return {
|
| 382 |
+
"success": True,
|
| 383 |
+
"message": f"Translated and uploaded to Google Drive",
|
| 384 |
+
"source": result.get("source"),
|
| 385 |
+
"page_count": result.get("page_count"),
|
| 386 |
+
"gdrive_id": file_id,
|
| 387 |
+
"gdrive_link": f"https://drive.google.com/file/d/{file_id}/view",
|
| 388 |
+
"local_file": result["output_file"],
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@mcp.tool()
|
| 393 |
+
async def check_pdf(source: str) -> dict:
|
| 394 |
+
"""Check if PDF can be translated (page count)."""
|
| 395 |
+
await _warmup_modal()
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
pdf_bytes, source_name, error = await _get_pdf_bytes(source)
|
| 399 |
+
if error:
|
| 400 |
+
return {"success": False, "message": error}
|
| 401 |
+
|
| 402 |
+
page_count = _count_pdf_pages(pdf_bytes)
|
| 403 |
+
can_translate = page_count <= MAX_PAGES
|
| 404 |
+
|
| 405 |
+
return {
|
| 406 |
+
"success": True,
|
| 407 |
+
"source": source_name,
|
| 408 |
+
"pages": page_count,
|
| 409 |
+
"size_mb": round(len(pdf_bytes) / (1024 * 1024), 2),
|
| 410 |
+
"can_translate": can_translate,
|
| 411 |
+
"message": f"Ready ({page_count} pages)" if can_translate else f"Too large ({page_count} > {MAX_PAGES})"
|
| 412 |
+
}
|
| 413 |
+
except Exception as e:
|
| 414 |
+
return {"success": False, "message": f"Error: {str(e)}"}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
@mcp.tool()
|
| 418 |
+
async def get_supported_languages() -> dict:
|
| 419 |
+
"""Get supported languages."""
|
| 420 |
+
return {"languages": SUPPORTED_LANGUAGES, "default": "fr"}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
@mcp.tool()
|
| 424 |
+
async def upload_to_gdrive(file_path: str, folder_id: Optional[str] = None) -> dict:
|
| 425 |
+
"""Upload file to Google Drive."""
|
| 426 |
+
if not GOOGLE_AVAILABLE:
|
| 427 |
+
return {"success": False, "message": "Google libraries not installed"}
|
| 428 |
+
|
| 429 |
+
path = Path(file_path)
|
| 430 |
+
if not path.exists():
|
| 431 |
+
return {"success": False, "message": f"File not found: {file_path}"}
|
| 432 |
+
|
| 433 |
+
file_id, error = _upload_to_gdrive(file_path, folder_id)
|
| 434 |
+
if error:
|
| 435 |
+
return {"success": False, "message": error}
|
| 436 |
+
|
| 437 |
+
return {
|
| 438 |
+
"success": True,
|
| 439 |
+
"message": f"Uploaded {path.name}",
|
| 440 |
+
"file_id": file_id,
|
| 441 |
+
"web_link": f"https://drive.google.com/file/d/{file_id}/view",
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
@mcp.tool()
|
| 446 |
+
async def list_gdrive_folders() -> dict:
|
| 447 |
+
"""List Google Drive folders."""
|
| 448 |
+
if not GOOGLE_AVAILABLE:
|
| 449 |
+
return {"success": False, "message": "Google libraries not installed"}
|
| 450 |
+
|
| 451 |
+
folders, error = _list_gdrive_folders()
|
| 452 |
+
if error:
|
| 453 |
+
return {"success": False, "message": error}
|
| 454 |
+
|
| 455 |
+
return {"success": True, "folders": folders, "count": len(folders)}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@mcp.tool()
|
| 459 |
+
async def list_gdrive_files(folder_id: Optional[str] = None, file_type: Optional[str] = None) -> dict:
|
| 460 |
+
"""List files in Google Drive."""
|
| 461 |
+
if not GOOGLE_AVAILABLE:
|
| 462 |
+
return {"success": False, "message": "Google libraries not installed"}
|
| 463 |
+
|
| 464 |
+
files, error = _list_gdrive_files(folder_id, file_type)
|
| 465 |
+
if error:
|
| 466 |
+
return {"success": False, "message": error}
|
| 467 |
+
|
| 468 |
+
for f in files:
|
| 469 |
+
if f.get('size'):
|
| 470 |
+
f['size_mb'] = round(int(f['size']) / (1024 * 1024), 2)
|
| 471 |
+
|
| 472 |
+
return {"success": True, "files": files, "count": len(files)}
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
@mcp.tool()
|
| 476 |
+
async def search_gdrive(query: str, file_type: Optional[str] = None) -> dict:
|
| 477 |
+
"""Search Google Drive by filename."""
|
| 478 |
+
if not GOOGLE_AVAILABLE:
|
| 479 |
+
return {"success": False, "message": "Google libraries not installed"}
|
| 480 |
+
|
| 481 |
+
files, error = _search_gdrive_files(query, file_type)
|
| 482 |
+
if error:
|
| 483 |
+
return {"success": False, "message": error}
|
| 484 |
+
|
| 485 |
+
for f in files:
|
| 486 |
+
if f.get('size'):
|
| 487 |
+
f['size_mb'] = round(int(f['size']) / (1024 * 1024), 2)
|
| 488 |
+
|
| 489 |
+
return {"success": True, "query": query, "files": files, "count": len(files)}
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
@mcp.tool()
|
| 493 |
+
async def download_from_gdrive(file_id: str) -> dict:
|
| 494 |
+
"""Download file from Google Drive."""
|
| 495 |
+
if not GOOGLE_AVAILABLE:
|
| 496 |
+
return {"success": False, "message": "Google libraries not installed"}
|
| 497 |
+
|
| 498 |
+
local_path, error = _download_gdrive_file(file_id)
|
| 499 |
+
if error:
|
| 500 |
+
return {"success": False, "message": error}
|
| 501 |
+
|
| 502 |
+
return {
|
| 503 |
+
"success": True,
|
| 504 |
+
"message": f"Downloaded to {local_path}",
|
| 505 |
+
"local_path": local_path,
|
| 506 |
+
"filename": Path(local_path).name,
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
if __name__ == "__main__":
|
| 511 |
+
mcp.run()
|