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 ine34711d). 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 seite34711dkaputt gewesen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhHNJKk3RsHgUsJ27WQSom
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e34711d0ca
commit
4d198017df
+103
@@ -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.]")]
|
||||
Reference in New Issue
Block a user