Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -6,13 +6,17 @@ import json
|
|
| 6 |
import traceback
|
| 7 |
import unicodedata
|
| 8 |
import re
|
|
|
|
| 9 |
from fastapi import FastAPI, File, UploadFile, Form
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
| 12 |
import fitz # pymupdf
|
| 13 |
from docx import Document
|
| 14 |
-
from docx.shared import Pt, Inches
|
| 15 |
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
|
|
|
|
|
| 16 |
import requests
|
| 17 |
|
| 18 |
app = FastAPI(title="Document Enhancer v5")
|
|
@@ -214,14 +218,15 @@ def extract_text_and_layout(file_bytes: bytes, kind: str):
|
|
| 214 |
def enhance_text_with_gemini(text: str, doc_type: str = "auto", user_prompt: str = "") -> str:
|
| 215 |
"""
|
| 216 |
Send prompt to Gemini to produce JSON document layout for ANY document type.
|
|
|
|
| 217 |
For very long documents, uses a summarization strategy.
|
| 218 |
Returns the raw string response (may include fences), caller should clean it.
|
| 219 |
|
| 220 |
doc_type: "auto", "resume", "letter", "report", "article", "essay", etc.
|
| 221 |
user_prompt: Custom instructions from user
|
| 222 |
"""
|
| 223 |
-
if not
|
| 224 |
-
return json.dumps({"error": "GEMINI_API_KEY
|
| 225 |
|
| 226 |
# For very long documents, use a more concise output strategy
|
| 227 |
is_long_doc = len(text) > 10000
|
|
@@ -294,24 +299,61 @@ IMPORTANT:
|
|
| 294 |
}
|
| 295 |
}
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
#
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
|
| 317 |
# -------------------------
|
|
@@ -642,6 +684,93 @@ async def route_full(
|
|
| 642 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 643 |
|
| 644 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
# -------------------------
|
| 646 |
# Root
|
| 647 |
# -------------------------
|
|
@@ -655,6 +784,7 @@ def root():
|
|
| 655 |
"endpoints": {
|
| 656 |
"/extract": "Extract plain text from document",
|
| 657 |
"/enhance": "Full pipeline: extract + AI enhancement + DOCX (RECOMMENDED)",
|
|
|
|
| 658 |
"/preview": "Preview JSON layout without downloading",
|
| 659 |
"/design": "Same as /enhance (legacy)",
|
| 660 |
"/full": "Same as /enhance (legacy)"
|
|
@@ -670,6 +800,7 @@ def root():
|
|
| 670 |
def health():
|
| 671 |
return {
|
| 672 |
"status": "healthy",
|
| 673 |
-
"
|
| 674 |
-
"
|
|
|
|
| 675 |
}
|
|
|
|
| 6 |
import traceback
|
| 7 |
import unicodedata
|
| 8 |
import re
|
| 9 |
+
import base64
|
| 10 |
from fastapi import FastAPI, File, UploadFile, Form
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 13 |
+
from typing import Optional
|
| 14 |
import fitz # pymupdf
|
| 15 |
from docx import Document
|
| 16 |
+
from docx.shared import Pt, Inches, RGBColor
|
| 17 |
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 18 |
+
from docx.oxml.ns import qn
|
| 19 |
+
from PIL import Image
|
| 20 |
import requests
|
| 21 |
|
| 22 |
app = FastAPI(title="Document Enhancer v5")
|
|
|
|
| 218 |
def enhance_text_with_gemini(text: str, doc_type: str = "auto", user_prompt: str = "") -> str:
|
| 219 |
"""
|
| 220 |
Send prompt to Gemini to produce JSON document layout for ANY document type.
|
| 221 |
+
Tries multiple API keys if one fails (rate limit, quota exceeded, etc.).
|
| 222 |
For very long documents, uses a summarization strategy.
|
| 223 |
Returns the raw string response (may include fences), caller should clean it.
|
| 224 |
|
| 225 |
doc_type: "auto", "resume", "letter", "report", "article", "essay", etc.
|
| 226 |
user_prompt: Custom instructions from user
|
| 227 |
"""
|
| 228 |
+
if not GEMINI_API_KEYS:
|
| 229 |
+
return json.dumps({"error": "No GEMINI_API_KEY configured"})
|
| 230 |
|
| 231 |
# For very long documents, use a more concise output strategy
|
| 232 |
is_long_doc = len(text) > 10000
|
|
|
|
| 299 |
}
|
| 300 |
}
|
| 301 |
|
| 302 |
+
# Try each API key in sequence until one works
|
| 303 |
+
last_error = None
|
| 304 |
+
for idx, api_key in enumerate(GEMINI_API_KEYS):
|
| 305 |
+
try:
|
| 306 |
+
print(f"Trying Gemini API key #{idx + 1}/{len(GEMINI_API_KEYS)}...")
|
| 307 |
+
|
| 308 |
+
res = requests.post(
|
| 309 |
+
GEMINI_URL + f"?key={api_key}",
|
| 310 |
+
json=payload,
|
| 311 |
+
timeout=240
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
# Check for rate limit or quota errors
|
| 315 |
+
if res.status_code == 429:
|
| 316 |
+
print(f"API key #{idx + 1} rate limited, trying next...")
|
| 317 |
+
last_error = "Rate limit exceeded"
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
if res.status_code == 403:
|
| 321 |
+
print(f"API key #{idx + 1} quota exceeded or invalid, trying next...")
|
| 322 |
+
last_error = "Quota exceeded or invalid API key"
|
| 323 |
+
continue
|
| 324 |
+
|
| 325 |
+
res.raise_for_status()
|
| 326 |
+
resp_json = res.json()
|
| 327 |
+
|
| 328 |
+
# Extract content
|
| 329 |
+
content = resp_json["candidates"][0]["content"]["parts"][0]["text"]
|
| 330 |
+
|
| 331 |
+
# Check if response looks truncated
|
| 332 |
+
if not content.rstrip().endswith("}") and not content.rstrip().endswith("]"):
|
| 333 |
+
content = repair_truncated_json(content)
|
| 334 |
+
|
| 335 |
+
print(f"✓ API key #{idx + 1} succeeded!")
|
| 336 |
+
return content
|
| 337 |
+
|
| 338 |
+
except requests.exceptions.Timeout:
|
| 339 |
+
print(f"API key #{idx + 1} timed out, trying next...")
|
| 340 |
+
last_error = "Request timed out"
|
| 341 |
+
continue
|
| 342 |
+
|
| 343 |
+
except requests.exceptions.RequestException as e:
|
| 344 |
+
print(f"API key #{idx + 1} failed: {str(e)}, trying next...")
|
| 345 |
+
last_error = str(e)
|
| 346 |
+
continue
|
| 347 |
+
|
| 348 |
+
except Exception as e:
|
| 349 |
+
print(f"API key #{idx + 1} error: {str(e)}, trying next...")
|
| 350 |
+
last_error = str(e)
|
| 351 |
+
continue
|
| 352 |
+
|
| 353 |
+
# All API keys failed
|
| 354 |
+
return json.dumps({
|
| 355 |
+
"error": f"All {len(GEMINI_API_KEYS)} API keys failed. Last error: {last_error}"
|
| 356 |
+
})
|
| 357 |
|
| 358 |
|
| 359 |
# -------------------------
|
|
|
|
| 684 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 685 |
|
| 686 |
|
| 687 |
+
@app.post("/add-signature")
|
| 688 |
+
async def route_add_signature(
|
| 689 |
+
file: UploadFile = File(...),
|
| 690 |
+
signature: str = Form(...), # Base64 encoded image
|
| 691 |
+
position: str = Form("bottom-right"), # bottom-right, bottom-center, bottom-left, custom
|
| 692 |
+
signer_name: Optional[str] = Form(None)
|
| 693 |
+
):
|
| 694 |
+
"""
|
| 695 |
+
Add a signature image to an existing DOCX file.
|
| 696 |
+
|
| 697 |
+
Parameters:
|
| 698 |
+
- file: DOCX file to sign
|
| 699 |
+
- signature: Base64 encoded signature image (PNG/JPEG)
|
| 700 |
+
- position: Where to place signature (bottom-right, bottom-center, bottom-left)
|
| 701 |
+
- signer_name: Optional name to display under signature
|
| 702 |
+
"""
|
| 703 |
+
try:
|
| 704 |
+
# Read the DOCX file
|
| 705 |
+
docx_bytes = await file.read()
|
| 706 |
+
doc = Document(io.BytesIO(docx_bytes))
|
| 707 |
+
|
| 708 |
+
# Decode signature image from base64
|
| 709 |
+
try:
|
| 710 |
+
# Remove data URI prefix if present (data:image/png;base64,...)
|
| 711 |
+
if ',' in signature:
|
| 712 |
+
signature = signature.split(',', 1)[1]
|
| 713 |
+
|
| 714 |
+
signature_bytes = base64.b64decode(signature)
|
| 715 |
+
signature_image = io.BytesIO(signature_bytes)
|
| 716 |
+
|
| 717 |
+
# Validate it's a valid image
|
| 718 |
+
img = Image.open(signature_image)
|
| 719 |
+
signature_image.seek(0) # Reset stream position
|
| 720 |
+
|
| 721 |
+
except Exception as e:
|
| 722 |
+
return JSONResponse({"error": f"Invalid signature image: {str(e)}"}, status_code=400)
|
| 723 |
+
|
| 724 |
+
# Get the last section (last page)
|
| 725 |
+
last_section = doc.sections[-1]
|
| 726 |
+
|
| 727 |
+
# Add signature at the end of document
|
| 728 |
+
# Add some spacing before signature
|
| 729 |
+
doc.add_paragraph()
|
| 730 |
+
|
| 731 |
+
# Create signature paragraph
|
| 732 |
+
sig_paragraph = doc.add_paragraph()
|
| 733 |
+
|
| 734 |
+
# Set alignment based on position
|
| 735 |
+
if position == "bottom-center":
|
| 736 |
+
sig_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 737 |
+
elif position == "bottom-left":
|
| 738 |
+
sig_paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
| 739 |
+
else: # bottom-right (default)
|
| 740 |
+
sig_paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 741 |
+
|
| 742 |
+
# Add signature image (resize to reasonable size)
|
| 743 |
+
run = sig_paragraph.add_run()
|
| 744 |
+
run.add_picture(signature_image, width=Inches(1.5))
|
| 745 |
+
|
| 746 |
+
# Add signer name if provided
|
| 747 |
+
if signer_name and signer_name.strip():
|
| 748 |
+
name_paragraph = doc.add_paragraph()
|
| 749 |
+
name_paragraph.alignment = sig_paragraph.alignment
|
| 750 |
+
name_run = name_paragraph.add_run(signer_name.strip())
|
| 751 |
+
name_run.font.size = Pt(10)
|
| 752 |
+
name_run.font.italic = True
|
| 753 |
+
|
| 754 |
+
# Save to bytes
|
| 755 |
+
output = io.BytesIO()
|
| 756 |
+
doc.save(output)
|
| 757 |
+
output.seek(0)
|
| 758 |
+
|
| 759 |
+
safe_filename = sanitize_filename(f"Signed_{file.filename or 'document.docx'}")
|
| 760 |
+
|
| 761 |
+
return StreamingResponse(
|
| 762 |
+
output,
|
| 763 |
+
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 764 |
+
headers={
|
| 765 |
+
"Content-Disposition": f'attachment; filename="{safe_filename}"'
|
| 766 |
+
},
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
except Exception as e:
|
| 770 |
+
traceback.print_exc()
|
| 771 |
+
return JSONResponse({"error": f"Failed to add signature: {str(e)}"}, status_code=500)
|
| 772 |
+
|
| 773 |
+
|
| 774 |
# -------------------------
|
| 775 |
# Root
|
| 776 |
# -------------------------
|
|
|
|
| 784 |
"endpoints": {
|
| 785 |
"/extract": "Extract plain text from document",
|
| 786 |
"/enhance": "Full pipeline: extract + AI enhancement + DOCX (RECOMMENDED)",
|
| 787 |
+
"/add-signature": "Add signature to existing DOCX file",
|
| 788 |
"/preview": "Preview JSON layout without downloading",
|
| 789 |
"/design": "Same as /enhance (legacy)",
|
| 790 |
"/full": "Same as /enhance (legacy)"
|
|
|
|
| 800 |
def health():
|
| 801 |
return {
|
| 802 |
"status": "healthy",
|
| 803 |
+
"api_keys_configured": len(GEMINI_API_KEYS),
|
| 804 |
+
"api_keys_available": GEMINI_API_KEYS[:1] if GEMINI_API_KEYS else [], # Only show first key for security
|
| 805 |
+
"version": "5.0 - Universal with Prompt Support + Multi-API-Key Fallback"
|
| 806 |
}
|