feat: Office-Extraktion (docx/xlsx/pptx) fuer files-, mail- und notes-MCP

officeutil.py (analog pdfutil): server-seitige Text-Extraktion, damit das
LLM Inhalte als Text bekommt statt Base64-Blob. Verkabelt in files.read_file,
mail.read_attachment (und notes.read_resource, bereits in e34711d).
Aeltere Binaerformate (.doc/.xls/.ppt) und .rtf bewusst nicht behandelt --
is_office() False -> Fallback auf Rohdaten. requirements-extra: python-pptx.

War bereits deployed und lief (FileTypes-Tests 14 passed), lag aber
uncommittet im Arbeitsverzeichnis; ohne officeutil.py waere ein frischer
Clone seit e34711d kaputt gewesen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom
This commit is contained in:
Stefan Lohmaier
2026-07-21 09:32:26 +02:00
co-authored by Claude Opus 4.8
parent e34711d0ca
commit 4d198017df
4 changed files with 112 additions and 2 deletions
+4 -1
View File
@@ -15,6 +15,7 @@ from starlette.routing import Mount
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware
from pdfutil import pdf_to_content
from officeutil import office_to_content, is_office
from common import load_config as _lc
_cfg = _lc()
@@ -104,7 +105,7 @@ def _guess_mime(path, ct):
def read_file(
path: Annotated[str, Field(description="Full file path, e.g. '/Documents/notes.txt', '/report.pdf', '/photo.jpg'")],
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Read a file. Text files return content directly. Images inline. PDFs as extracted text. Other documents (docx, xlsx, pptx) as binary. Max 25 MB."""
"""Read a file. Text files return content directly. Images inline. PDFs and Office documents (docx, xlsx, pptx) as extracted text. Max 25 MB."""
user = get_current_user()
if not user: return [TextContent(type="text", text="Error: not authenticated")]
r = httpx.get(_dav(user, path), auth=_auth(user), timeout=60)
@@ -119,6 +120,8 @@ def read_file(
return [TextContent(type="text", text=r.text[:100000])]
if ct == "application/pdf" or path.lower().endswith(".pdf"):
return pdf_to_content(r.content, path, ct)
if is_office(ct, path):
return office_to_content(r.content, path, ct)
try:
text = r.content.decode("utf-8")
return [TextContent(type="text", text=text[:100000])]
+4 -1
View File
@@ -25,6 +25,7 @@ from starlette.routing import Mount
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from common import get_current_user, OAUTH_ROUTES, BearerAuthMiddleware
from pdfutil import pdf_to_content
from officeutil import office_to_content, is_office
from common import load_config as _lc
_cfg = _lc()
@@ -343,7 +344,7 @@ def read_attachment(
key: Annotated[str, Field(description="Message key from search results")],
attachment_index: Annotated[int, Field(description="Attachment number from the read_mail attachment list (1-based)")],
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Read an email attachment. Images shown inline, PDFs as extracted text, text directly, other documents as binary. Get the index from read_mail."""
"""Read an email attachment. Images shown inline, PDFs and Office documents (docx, xlsx, pptx) as extracted text, text directly, other formats as binary. Get the index from read_mail."""
_trim_memory()
user = get_current_user()
if not user:
@@ -369,6 +370,8 @@ def read_attachment(
return [TextContent(type="text", text=payload.decode("utf-8", errors="replace")[:100000])]
if mime == "application/pdf" or att["filename"].lower().endswith(".pdf"):
return pdf_to_content(payload, att["filename"], mime, uri=f"mail://attachment/{att['filename']}")
if is_office(mime, att["filename"]):
return office_to_content(payload, att["filename"], mime, uri=f"mail://attachment/{att['filename']}")
return [EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=f"mail://attachment/{att['filename']}",
blob=base64.b64encode(payload).decode(), mimeType=mime))]
+103
View File
@@ -0,0 +1,103 @@
"""Office-Dokumente (docx/xlsx/pptx) -> extrahierter Text fuer Files- und Mail-MCP.
Analog zu pdfutil: server-seitige Extraktion, damit das LLM (OpenClaw wie claude.ai)
den Inhalt als Text bekommt statt eines Base64-Blobs. Aeltere Binaerformate
(.doc/.xls/.ppt) und .rtf koennen diese Libs nicht -> hier NICHT behandelt
(is_office() liefert False, Aufrufer faellt auf Rohdaten zurueck).
"""
import base64
import io
from mcp.types import TextContent, EmbeddedResource, BlobResourceContents
MAX_TEXT = 200000
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
_EXT = {".docx": DOCX, ".xlsx": XLSX, ".pptx": PPTX}
def _docx_text(content):
import docx
d = docx.Document(io.BytesIO(content))
parts = []
for p in d.paragraphs:
if p.text.strip():
parts.append(p.text)
for ti, tbl in enumerate(d.tables, 1):
parts.append(f"[Tabelle {ti}]")
for row in tbl.rows:
parts.append(" | ".join(c.text.strip() for c in row.cells))
return "\n".join(parts)
def _xlsx_text(content):
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
parts = []
try:
for ws in wb.worksheets:
parts.append(f"--- Blatt: {ws.title} ---")
for row in ws.iter_rows(values_only=True):
if any(v is not None for v in row):
parts.append("\t".join("" if v is None else str(v) for v in row))
finally:
wb.close()
return "\n".join(parts)
def _pptx_text(content):
from pptx import Presentation
prs = Presentation(io.BytesIO(content))
parts = []
for i, slide in enumerate(prs.slides, 1):
parts.append(f"--- Folie {i} ---")
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
t = "".join(run.text for run in para.runs)
if t.strip():
parts.append(t)
if getattr(shape, "has_table", False):
for row in shape.table.rows:
parts.append(" | ".join(c.text for c in row.cells))
return "\n".join(parts)
_HANDLERS = {
DOCX: ("Word-Dokument", _docx_text),
XLSX: ("Excel-Tabelle", _xlsx_text),
PPTX: ("PowerPoint", _pptx_text),
}
def _resolve(mime, label):
if mime in _HANDLERS:
return _HANDLERS[mime]
ext = "." + label.rsplit(".", 1)[-1].lower() if "." in label else ""
m = _EXT.get(ext)
return _HANDLERS.get(m) if m else None
def is_office(mime, label=""):
"""True fuer docx/xlsx/pptx (per MIME oder Endung)."""
return _resolve(mime, label) is not None
def office_to_content(content, label, mime="", uri=None):
uri = uri or f"file://{label}"
kind, fn = _resolve(mime, label)
try:
text = fn(content)
except Exception as e:
return [
TextContent(type="text", text=f"[{kind} '{label}' konnte nicht gelesen werden ({e}). Rohdaten folgen.]"),
EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=uri, blob=base64.b64encode(content).decode(),
mimeType=mime or "application/octet-stream")),
]
if text.strip():
return [TextContent(type="text", text=f"[{kind}: {label}]\n\n{text[:MAX_TEXT]}")]
return [TextContent(type="text", text=f"[{kind} '{label}' enthaelt keinen extrahierbaren Text.]")]
+1
View File
@@ -7,3 +7,4 @@ pillow==12.2.0
PyMuPDF==1.27.2.3
pytesseract==0.3.13
python-docx==1.2.0
python-pptx==1.0.2