mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
Consolidate Odysseus agent harness and tool contracts
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
@@ -320,6 +323,190 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ---- POST /api/documents/import-docx ----
|
||||
@router.post("/api/documents/import-docx")
|
||||
async def import_docx(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
session_id: Optional[str] = Form(None),
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a Word document while preserving its original DOCX upload.
|
||||
|
||||
The extracted Markdown remains available to the agent/editor, while
|
||||
the source marker lets the document viewer render a faithful white
|
||||
paper preview through Mammoth.
|
||||
"""
|
||||
from src.auth_helpers import require_privilege
|
||||
from src.markitdown_runtime import convert_to_markdown
|
||||
from src.office_doc import create_office_document
|
||||
|
||||
user = require_privilege(request, "can_use_documents")
|
||||
if session_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_session_or_404(db, session_id, user)
|
||||
finally:
|
||||
db.close()
|
||||
if upload_handler is None:
|
||||
raise HTTPException(500, "Upload handler not configured")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
try:
|
||||
meta = upload_handler.save_upload(file, client_ip, owner=user)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("DOCX import save_upload failed: %s", exc)
|
||||
raise HTTPException(500, f"Upload failed: {exc}") from exc
|
||||
|
||||
upload_id = meta["id"]
|
||||
path = _locate_current_user_upload(request, upload_id, user)
|
||||
if not path:
|
||||
raise HTTPException(500, "Saved DOCX could not be located")
|
||||
try:
|
||||
extracted = await asyncio.to_thread(convert_to_markdown, path) or ""
|
||||
except Exception as exc:
|
||||
logger.warning("DOCX text extraction failed for %s: %s", path, exc)
|
||||
extracted = ""
|
||||
if not extracted.strip():
|
||||
raise HTTPException(422, "Could not extract readable text from this DOCX")
|
||||
|
||||
title = os.path.splitext(meta.get("original_name") or meta.get("name") or upload_id)[0]
|
||||
content = f'<!-- docx_source upload_id="{upload_id}" -->\n{extracted}'
|
||||
doc_id = create_office_document(
|
||||
session_id=session_id,
|
||||
upload_id=upload_id,
|
||||
title=title,
|
||||
body_text=content,
|
||||
language="docx",
|
||||
owner=user,
|
||||
)
|
||||
if not doc_id:
|
||||
raise HTTPException(500, "Failed to create DOCX document")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
if not doc:
|
||||
raise HTTPException(500, "Created DOCX document not found")
|
||||
if not doc.owner and user:
|
||||
doc.owner = user
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_dict(doc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/api/document/{doc_id}/render-docx")
|
||||
async def render_docx(doc_id: str, request: Request) -> Dict[str, Any]:
|
||||
"""Return a sanitized-by-client DOCX-to-HTML preview fragment."""
|
||||
from src.auth_helpers import require_privilege
|
||||
|
||||
user = require_privilege(request, "can_use_documents")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
_verify_doc_owner(db, doc, user)
|
||||
match = re.search(r'<!--\s*docx_source\s+upload_id="([^"]+)"\s*-->', doc.current_content or "")
|
||||
if not match:
|
||||
raise HTTPException(400, "Document has no DOCX source")
|
||||
path = _locate_current_user_upload(request, match.group(1), user)
|
||||
if not path:
|
||||
raise HTTPException(404, "Original DOCX upload is no longer available")
|
||||
try:
|
||||
import mammoth
|
||||
result = await asyncio.to_thread(mammoth.convert_to_html, str(path))
|
||||
except ImportError as exc:
|
||||
raise HTTPException(503, "DOCX preview needs the Mammoth document dependency") from exc
|
||||
except Exception as exc:
|
||||
logger.warning("DOCX preview failed for %s: %s", doc_id, exc)
|
||||
raise HTTPException(422, "Could not render this DOCX preview") from exc
|
||||
return {"html": result.value or "", "messages": [str(m) for m in (result.messages or [])]}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/api/document/{doc_id}/convert-original/{target}")
|
||||
async def convert_original_document(doc_id: str, target: str, request: Request):
|
||||
"""Convert the preserved DOCX/PDF upload directly with LibreOffice.
|
||||
|
||||
The extracted Markdown is for search and AI context only. It must not
|
||||
be used as an intermediate for format conversion because that loses
|
||||
the original document's layout, tables, and page breaks.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from fastapi.responses import Response
|
||||
from src.auth_helpers import require_privilege
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
|
||||
if target not in {"pdf", "docx"}:
|
||||
raise HTTPException(400, "Unsupported conversion target")
|
||||
|
||||
user = require_privilege(request, "can_use_documents")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
_verify_doc_owner(db, doc, user)
|
||||
content = doc.current_content or ""
|
||||
match = re.search(
|
||||
r'<!--\s*(?:docx|pdf(?:_form)?)_source\s+upload_id="([^"\s]+)"\s*-->',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
upload_id = find_source_upload_id(content) or (match.group(1) if match else None)
|
||||
if not upload_id:
|
||||
raise HTTPException(400, "This document has no preserved original file")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
source = _locate_current_user_upload(request, upload_id, user)
|
||||
if not source:
|
||||
raise HTTPException(404, "Original upload not found")
|
||||
source = Path(source)
|
||||
source_ext = source.suffix.lower()
|
||||
if target == "pdf" and source_ext != ".docx":
|
||||
raise HTTPException(400, "Only DOCX documents can be converted to PDF")
|
||||
if target == "docx" and source_ext != ".pdf":
|
||||
raise HTTPException(400, "Only PDF documents can be converted to DOCX")
|
||||
|
||||
soffice = shutil.which("soffice") or shutil.which("libreoffice")
|
||||
if not soffice:
|
||||
raise HTTPException(503, "Direct conversion requires LibreOffice/soffice on the Odysseus host")
|
||||
|
||||
def convert():
|
||||
# Keep cleanup in the worker too: request cancellation must not
|
||||
# delete files while LibreOffice is still writing them.
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-document-convert-") as temp:
|
||||
tmp_dir = Path(temp)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[soffice, f"-env:UserInstallation={(tmp_dir / 'profile').as_uri()}",
|
||||
"--headless", "--convert-to", target, "--outdir", str(tmp_dir), str(source)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, timeout=120, check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(504, "Document conversion timed out") from exc
|
||||
output = tmp_dir / f"{source.stem}.{target}"
|
||||
if proc.returncode != 0 or not output.exists() or output.stat().st_size == 0:
|
||||
raise HTTPException(502, "LibreOffice could not convert the original file")
|
||||
return output.read_bytes()
|
||||
|
||||
payload = await asyncio.to_thread(convert)
|
||||
|
||||
media = "application/pdf" if target == "pdf" else "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f'attachment; filename="{source.stem}.{target}"'},
|
||||
)
|
||||
|
||||
# ---- GET /api/documents/library ----
|
||||
@router.get("/api/documents/library")
|
||||
async def documents_library(
|
||||
|
||||
Reference in New Issue
Block a user