From 4d198017df92ab9b0b75fb38acd2eb4f7d436a2c Mon Sep 17 00:00:00 2001 From: Stefan Lohmaier Date: Tue, 21 Jul 2026 09:32:26 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom --- files/server.py | 5 +- mail/server.py | 5 +- officeutil.py | 103 +++++++++++++++++++++++++++++++++++++++++ requirements-extra.txt | 1 + 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 officeutil.py diff --git a/files/server.py b/files/server.py index 99c4e0d..6ef5091 100644 --- a/files/server.py +++ b/files/server.py @@ -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])] diff --git a/mail/server.py b/mail/server.py index ca053b1..0f5c39f 100644 --- a/mail/server.py +++ b/mail/server.py @@ -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))] diff --git a/officeutil.py b/officeutil.py new file mode 100644 index 0000000..c78768d --- /dev/null +++ b/officeutil.py @@ -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.]")] diff --git a/requirements-extra.txt b/requirements-extra.txt index 496219d..4825a04 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -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