Add Next.js frontend with WebLLM, OpenAI support - Add complete Next.js frontend with Tailwind CSS and shadcn/ui - Integrate WebLLM for client-side browser-based translations - Add OpenAI provider support with gpt-4o-mini default - Add Context & Glossary page for LLM customization - Reorganize settings: Translation Services includes all providers - Add system prompt and glossary support for all LLMs - Remove test files and requirements-test.txt
This commit is contained in:
291
main.py
291
main.py
@@ -19,6 +19,27 @@ from utils import file_handler, handle_translation_error, DocumentProcessingErro
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_full_prompt(system_prompt: str, glossary: str) -> str:
|
||||
"""Combine system prompt and glossary into a single prompt for LLM translation."""
|
||||
parts = []
|
||||
|
||||
# Add system prompt if provided
|
||||
if system_prompt and system_prompt.strip():
|
||||
parts.append(system_prompt.strip())
|
||||
|
||||
# Add glossary if provided
|
||||
if glossary and glossary.strip():
|
||||
glossary_section = """
|
||||
TECHNICAL GLOSSARY - Use these exact translations for the following terms:
|
||||
{}
|
||||
|
||||
Always use the translations from this glossary when you encounter these terms.""".format(glossary.strip())
|
||||
parts.append(glossary_section)
|
||||
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
# Ensure necessary directories exist
|
||||
config.ensure_directories()
|
||||
|
||||
@@ -110,10 +131,14 @@ async def translate_document(
|
||||
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
|
||||
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
|
||||
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
|
||||
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre)"),
|
||||
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama model"),
|
||||
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre, openai)"),
|
||||
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama/OpenAI model"),
|
||||
ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"),
|
||||
system_prompt: str = Form(default="", description="Custom system prompt with context, glossary, or instructions for LLM translation"),
|
||||
system_prompt: str = Form(default="", description="Custom system prompt with context or instructions for LLM translation"),
|
||||
glossary: str = Form(default="", description="Technical glossary (format: source=target, one per line)"),
|
||||
libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"),
|
||||
openai_api_key: str = Form(default="", description="OpenAI API key"),
|
||||
openai_model: str = Form(default="gpt-4o-mini", description="OpenAI model to use (gpt-4o-mini is cheapest with vision)"),
|
||||
cleanup: bool = Form(default=True, description="Delete input file after translation")
|
||||
):
|
||||
"""
|
||||
@@ -156,18 +181,32 @@ async def translate_document(
|
||||
logger.info(f"Saved input file to: {input_path}")
|
||||
|
||||
# Configure translation provider
|
||||
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, translation_service
|
||||
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, translation_service
|
||||
|
||||
if provider.lower() == "deepl":
|
||||
if not config.DEEPL_API_KEY:
|
||||
raise HTTPException(status_code=400, detail="DeepL API key not configured")
|
||||
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
|
||||
elif provider.lower() == "libre":
|
||||
translation_provider = LibreTranslationProvider()
|
||||
libre_server = libre_url.strip() if libre_url else "https://libretranslate.com"
|
||||
logger.info(f"Using LibreTranslate server: {libre_server}")
|
||||
translation_provider = LibreTranslationProvider(libre_server)
|
||||
elif provider.lower() == "openai":
|
||||
api_key = openai_api_key.strip() if openai_api_key else ""
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="OpenAI API key not provided")
|
||||
model_to_use = openai_model.strip() if openai_model else "gpt-4o-mini"
|
||||
# Combine system prompt and glossary
|
||||
custom_prompt = build_full_prompt(system_prompt, glossary)
|
||||
logger.info(f"Using OpenAI model: {model_to_use}")
|
||||
if custom_prompt:
|
||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||
translation_provider = OpenAITranslationProvider(api_key, model_to_use, custom_prompt)
|
||||
elif provider.lower() == "ollama":
|
||||
# Use the same model for text and vision (multimodal models like gemma3, qwen3-vl)
|
||||
model_to_use = ollama_model.strip() if ollama_model else config.OLLAMA_MODEL
|
||||
custom_prompt = system_prompt.strip() if system_prompt else ""
|
||||
# Combine system prompt and glossary
|
||||
custom_prompt = build_full_prompt(system_prompt, glossary)
|
||||
logger.info(f"Using Ollama model: {model_to_use} (text + vision)")
|
||||
if custom_prompt:
|
||||
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
|
||||
@@ -378,6 +417,246 @@ async def configure_ollama(base_url: str = Form(...), model: str = Form(...)):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/extract-texts")
|
||||
async def extract_texts_from_document(
|
||||
file: UploadFile = File(..., description="Document file to extract texts from"),
|
||||
):
|
||||
"""
|
||||
Extract all translatable texts from a document for client-side translation (WebLLM).
|
||||
Returns a list of texts and a session ID to use for reconstruction.
|
||||
|
||||
**Parameters:**
|
||||
- **file**: The document file to extract texts from
|
||||
|
||||
**Returns:**
|
||||
- session_id: Unique ID to reference this extraction
|
||||
- texts: Array of texts to translate
|
||||
- file_type: Type of the document
|
||||
"""
|
||||
import uuid
|
||||
import json
|
||||
|
||||
try:
|
||||
# Validate file extension
|
||||
file_extension = file_handler.validate_file_extension(file.filename)
|
||||
logger.info(f"Extracting texts from {file_extension} file: {file.filename}")
|
||||
|
||||
# Validate file size
|
||||
file_handler.validate_file_size(file)
|
||||
|
||||
# Generate session ID
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
# Save uploaded file
|
||||
input_filename = f"session_{session_id}{file_extension}"
|
||||
input_path = config.UPLOAD_DIR / input_filename
|
||||
await file_handler.save_upload_file(file, input_path)
|
||||
|
||||
# Extract texts based on file type
|
||||
texts = []
|
||||
|
||||
if file_extension == ".xlsx":
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(input_path)
|
||||
for sheet in wb.worksheets:
|
||||
for row in sheet.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str) and cell.value.strip():
|
||||
texts.append({
|
||||
"id": f"{sheet.title}!{cell.coordinate}",
|
||||
"text": cell.value
|
||||
})
|
||||
wb.close()
|
||||
elif file_extension == ".docx":
|
||||
from docx import Document
|
||||
doc = Document(input_path)
|
||||
para_idx = 0
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
texts.append({
|
||||
"id": f"para_{para_idx}",
|
||||
"text": para.text
|
||||
})
|
||||
para_idx += 1
|
||||
# Also extract from tables
|
||||
table_idx = 0
|
||||
for table in doc.tables:
|
||||
for row_idx, row in enumerate(table.rows):
|
||||
for cell_idx, cell in enumerate(row.cells):
|
||||
if cell.text.strip():
|
||||
texts.append({
|
||||
"id": f"table_{table_idx}_r{row_idx}_c{cell_idx}",
|
||||
"text": cell.text
|
||||
})
|
||||
table_idx += 1
|
||||
elif file_extension == ".pptx":
|
||||
from pptx import Presentation
|
||||
prs = Presentation(input_path)
|
||||
for slide_idx, slide in enumerate(prs.slides):
|
||||
for shape_idx, shape in enumerate(slide.shapes):
|
||||
if shape.has_text_frame:
|
||||
for para_idx, para in enumerate(shape.text_frame.paragraphs):
|
||||
for run_idx, run in enumerate(para.runs):
|
||||
if run.text.strip():
|
||||
texts.append({
|
||||
"id": f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}",
|
||||
"text": run.text
|
||||
})
|
||||
|
||||
# Save session metadata
|
||||
session_data = {
|
||||
"original_filename": file.filename,
|
||||
"file_extension": file_extension,
|
||||
"input_path": str(input_path),
|
||||
"text_count": len(texts)
|
||||
}
|
||||
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
|
||||
with open(session_file, "w", encoding="utf-8") as f:
|
||||
json.dump(session_data, f)
|
||||
|
||||
logger.info(f"Extracted {len(texts)} texts from {file.filename}, session: {session_id}")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"texts": texts,
|
||||
"file_type": file_extension,
|
||||
"text_count": len(texts)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Text extraction error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to extract texts: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/reconstruct-document")
|
||||
async def reconstruct_document(
|
||||
session_id: str = Form(..., description="Session ID from extract-texts"),
|
||||
translations: str = Form(..., description="JSON array of {id, translated_text} objects"),
|
||||
target_language: str = Form(..., description="Target language code"),
|
||||
):
|
||||
"""
|
||||
Reconstruct a document with translated texts.
|
||||
|
||||
**Parameters:**
|
||||
- **session_id**: The session ID from extract-texts
|
||||
- **translations**: JSON array of translations with matching IDs
|
||||
- **target_language**: Target language for filename
|
||||
|
||||
**Returns:**
|
||||
- Translated document file
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
# Load session data
|
||||
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
|
||||
if not session_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Session not found or expired")
|
||||
|
||||
with open(session_file, "r", encoding="utf-8") as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
input_path = Path(session_data["input_path"])
|
||||
file_extension = session_data["file_extension"]
|
||||
original_filename = session_data["original_filename"]
|
||||
|
||||
if not input_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Source file not found or expired")
|
||||
|
||||
# Parse translations
|
||||
translation_list = json.loads(translations)
|
||||
translation_map = {t["id"]: t["translated_text"] for t in translation_list}
|
||||
|
||||
# Generate output path
|
||||
output_filename = file_handler.generate_unique_filename(original_filename, "translated")
|
||||
output_path = config.OUTPUT_DIR / output_filename
|
||||
|
||||
# Reconstruct based on file type
|
||||
if file_extension == ".xlsx":
|
||||
from openpyxl import load_workbook
|
||||
import shutil
|
||||
shutil.copy(input_path, output_path)
|
||||
wb = load_workbook(output_path)
|
||||
for sheet in wb.worksheets:
|
||||
for row in sheet.iter_rows():
|
||||
for cell in row:
|
||||
cell_id = f"{sheet.title}!{cell.coordinate}"
|
||||
if cell_id in translation_map:
|
||||
cell.value = translation_map[cell_id]
|
||||
wb.save(output_path)
|
||||
wb.close()
|
||||
|
||||
elif file_extension == ".docx":
|
||||
from docx import Document
|
||||
import shutil
|
||||
shutil.copy(input_path, output_path)
|
||||
doc = Document(output_path)
|
||||
para_idx = 0
|
||||
for para in doc.paragraphs:
|
||||
para_id = f"para_{para_idx}"
|
||||
if para_id in translation_map and para.text.strip():
|
||||
# Replace text while keeping formatting
|
||||
for run in para.runs:
|
||||
run.text = ""
|
||||
if para.runs:
|
||||
para.runs[0].text = translation_map[para_id]
|
||||
else:
|
||||
para.text = translation_map[para_id]
|
||||
para_idx += 1
|
||||
# Also handle tables
|
||||
table_idx = 0
|
||||
for table in doc.tables:
|
||||
for row_idx, row in enumerate(table.rows):
|
||||
for cell_idx, cell in enumerate(row.cells):
|
||||
cell_id = f"table_{table_idx}_r{row_idx}_c{cell_idx}"
|
||||
if cell_id in translation_map:
|
||||
# Clear and set new text
|
||||
for para in cell.paragraphs:
|
||||
for run in para.runs:
|
||||
run.text = ""
|
||||
if cell.paragraphs and cell.paragraphs[0].runs:
|
||||
cell.paragraphs[0].runs[0].text = translation_map[cell_id]
|
||||
elif cell.paragraphs:
|
||||
cell.paragraphs[0].text = translation_map[cell_id]
|
||||
table_idx += 1
|
||||
doc.save(output_path)
|
||||
|
||||
elif file_extension == ".pptx":
|
||||
from pptx import Presentation
|
||||
import shutil
|
||||
shutil.copy(input_path, output_path)
|
||||
prs = Presentation(output_path)
|
||||
for slide_idx, slide in enumerate(prs.slides):
|
||||
for shape_idx, shape in enumerate(slide.shapes):
|
||||
if shape.has_text_frame:
|
||||
for para_idx, para in enumerate(shape.text_frame.paragraphs):
|
||||
for run_idx, run in enumerate(para.runs):
|
||||
run_id = f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}"
|
||||
if run_id in translation_map:
|
||||
run.text = translation_map[run_id]
|
||||
prs.save(output_path)
|
||||
|
||||
# Cleanup session files
|
||||
file_handler.cleanup_file(input_path)
|
||||
file_handler.cleanup_file(session_file)
|
||||
|
||||
logger.info(f"Reconstructed document: {output_path}")
|
||||
|
||||
return FileResponse(
|
||||
path=output_path,
|
||||
filename=f"translated_{original_filename}",
|
||||
media_type="application/octet-stream"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Reconstruction error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reconstruct document: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
Reference in New Issue
Block a user