bifroest/app/documents/service.py
2026-10-27 08:34:17 +01:00

330 lines
16 KiB
Python

"""Abgeglichene Ordner: anlegen, abgleichen (Liste, Datei, Bereinigen), auslesen, Sichtbarkeit.
Abgleich: Der Browser holt die Liste (Pfad, Größe, Änderungszeit), lädt nur Neues und Geändertes einzeln hoch
und meldet zum Schluss alle vorhandenen Pfade; was fehlt, wird entfernt. Textdateien liest Bifröst sofort,
Office, PDF und Bilder der Worker über den MCP-Server mit der Rolle „documents“. Bis dahin liegt das Original
im Upload-Verzeichnis und wird nach der Auslese gelöscht.
"""
import base64
import dataclasses
import hashlib
import logging
from pathlib import Path, PurePosixPath
from typing import BinaryIO
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app import audit
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import Database, now
from app.core.errors import Forbidden, Invalid, NotFound
from app.documents.models import ERROR, OK, PENDING, PRIVATE, UNSUPPORTED, WORKSPACE, DocFile, DocFolder
from app.identity import Principal
from app.tools.registry import ToolError
from app.workspaces import service as workspaces
log = logging.getLogger(__name__)
TEXT = {".md", ".markdown", ".txt", ".text", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".html", ".htm",
".rst", ".log", ".ini", ".toml", ".adoc"}
# Liest der MCP-Server mit der Rolle „documents“
EXTRACTED = {".pdf", ".docx", ".doc", ".docm", ".xlsx", ".xls", ".xlsm", ".pptx", ".ppt", ".odt", ".ods", ".odp",
".rtf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".tif", ".tiff", ".bmp"}
SCOPES = {PRIVATE: "Nur ich", WORKSPACE: "Ganzer Workspace"}
CHUNK = 1024 * 1024
BATCH = 5 # Dateien je Auslese-Durchlauf des Workers
def suffix(path: str) -> str:
return PurePosixPath(path).suffix.lower()
def allowed(path: str) -> bool:
name = PurePosixPath(path).name
return suffix(path) in TEXT | EXTRACTED and not name.startswith((".", "~$"))
def check_path(path: str) -> str:
"""Relativer Pfad im Ordner, ohne .., ohne führenden Schrägstrich."""
path = path.replace("\\", "/").strip().strip("/")
parts = [p for p in path.split("/") if p not in ("", ".")]
if not parts or ".." in parts or len(path) > 500 or any(c in path for c in "\0\n\r"):
raise Invalid("Ungültiger Dateipfad.")
return "/".join(parts)
def pending_files(session: Session, settings: Settings, workspace_id: int) -> list[Path]:
"""Zwischendateien noch nicht ausgelesener Dateien eines Workspace (zum Aufräumen beim Löschen)."""
rows = session.scalars(select(DocFile).join(DocFolder, DocFolder.id == DocFile.folder_id)
.where(DocFolder.workspace_id == workspace_id, DocFile.status == PENDING))
return [pending_path(settings, f) for f in rows]
def pending_path(settings: Settings, file: DocFile) -> Path:
return Path(settings.upload_dir) / "documents" / f"{file.id}.bin"
# --- Ordner und Sichtbarkeit ---
def visible(query, user_id: int):
"""Ordner, die jemand sieht: alle des Workspace und die eigenen privaten."""
return query.where(or_(DocFolder.scope == WORKSPACE, DocFolder.owner_id == user_id))
def folders(session: Session, principal: Principal, workspace_id: int) -> list[DocFolder]:
workspaces.get(session, principal, workspace_id)
return list(session.scalars(visible(select(DocFolder).where(DocFolder.workspace_id == workspace_id),
principal.user_id).order_by(DocFolder.name)))
def for_run(session: Session, workspace_id: int, user_id: int | None, chat_id: int | None) -> list[DocFolder]:
"""Ordner, die ein Run lesen darf: die des Workspace und private nur, wenn die Person ihn in ihrem
eigenen Chat ausgelöst hat (in fremden Chats sähen andere die Antworten)."""
from app.chat.models import ChatSession
owner = None
if user_id is not None and chat_id is not None:
chat = session.get(ChatSession, chat_id)
owner = user_id if chat is not None and chat.created_by == user_id else None
condition = DocFolder.scope == WORKSPACE
if owner is not None:
condition = or_(condition, DocFolder.owner_id == owner)
return list(session.scalars(select(DocFolder).where(DocFolder.workspace_id == workspace_id, condition)
.order_by(DocFolder.name)))
def prompt_note(found: list[DocFolder]) -> str:
if not found:
return ""
lines = []
for f in found:
age = f"zuletzt abgeglichen {f.synced.astimezone().strftime('%d.%m.%Y %H:%M')}" if f.synced else "noch nie abgeglichen"
lines.append(f"- {f.name} ({'nur für die Person, die fragt' if f.scope == PRIVATE else 'Workspace'}; {age})")
return ("\nAbgeglichene Ordner mit Dokumenten (lesen mit docs_list, docs_search, docs_read; der Stand ist der "
"des letzten Abgleichs, nenne ihn bei Bedarf):\n" + "\n".join(lines) + "\n")
def folder(session: Session, principal: Principal, folder_id: int, write: bool = False) -> DocFolder:
"""Ordner, den die Person sieht; abgleichen darf nur, wer ihn angelegt hat."""
found = session.get(DocFolder, folder_id)
if found is None:
raise NotFound
workspaces.get(session, principal, found.workspace_id)
if found.scope == PRIVATE and found.owner_id != principal.user_id:
raise NotFound
if write and found.owner_id != principal.user_id:
raise Forbidden("Abgleichen kann nur, wer den Ordner verbunden hat.")
return found
def create(session: Session, principal: Principal, workspace_id: int, name: str, scope: str,
confirmed: bool) -> DocFolder:
workspaces.get(session, principal, workspace_id)
name = name.strip()[:100]
if not name:
raise Invalid("Name fehlt.")
if scope not in SCOPES:
raise Invalid("Unbekannte Sichtbarkeit.")
if scope == WORKSPACE and not confirmed:
raise Invalid("Bitte bestätigen, dass alle Mitglieder des Workspace diese Dokumente sehen dürfen.")
created = DocFolder(workspace_id=workspace_id, owner_id=principal.user_id, name=name, scope=scope,
exposure_confirmed=scope == WORKSPACE)
session.add(created)
session.flush()
audit.record(session, "documents.folder_created", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="doc_folder", target_id=created.id, name=name, scope=scope)
return created
def delete(session: Session, principal: Principal, settings: Settings, folder_id: int) -> int:
"""Löschen darf, wer den Ordner verbunden hat, bei Ordnern des Workspace auch ein Workspace-Admin."""
found = folder(session, principal, folder_id)
admin = found.scope == WORKSPACE and workspaces.get(session, principal, found.workspace_id).is_admin
if found.owner_id != principal.user_id and not admin:
raise Forbidden("Löschen kann nur, wer den Ordner verbunden hat, oder ein Workspace-Admin.")
for file in session.scalars(select(DocFile).where(DocFile.folder_id == found.id, DocFile.status == PENDING)):
pending_path(settings, file).unlink(missing_ok=True)
audit.record(session, "documents.folder_deleted", user_id=principal.user_id, workspace_id=found.workspace_id,
target_kind="doc_folder", target_id=found.id, name=found.name)
workspace_id = found.workspace_id
session.delete(found)
return workspace_id
@dataclasses.dataclass
class Summary:
files: int = 0
ok: int = 0
pending: int = 0
failed: int = 0 # Fehler oder ohne Auslese
size: int = 0 # Text in Bytes
def summaries(session: Session, folder_ids: list[int]) -> dict[int, Summary]:
result = {i: Summary() for i in folder_ids}
if not folder_ids:
return result
rows = session.execute(select(DocFile.folder_id, DocFile.status, func.count(),
func.coalesce(func.sum(func.octet_length(DocFile.text)), 0))
.where(DocFile.folder_id.in_(folder_ids)).group_by(DocFile.folder_id, DocFile.status))
for folder_id, status, count, size in rows:
s = result[folder_id]
s.files += count
s.size += int(size)
if status == OK:
s.ok += count
elif status == PENDING:
s.pending += count
else:
s.failed += count
return result
def files(session: Session, folder_id: int) -> list[DocFile]:
return list(session.scalars(select(DocFile).where(DocFile.folder_id == folder_id).order_by(DocFile.path)))
def used_bytes(session: Session, workspace_id: int) -> int:
"""Speicher der Ordner eines Workspace (ausgelesener Text); zählt fürs Kontingent."""
return int(session.scalar(select(func.coalesce(func.sum(func.octet_length(DocFile.text)), 0))
.join(DocFolder, DocFolder.id == DocFile.folder_id)
.where(DocFolder.workspace_id == workspace_id)) or 0)
# --- Abgleich ---
def manifest(session: Session, principal: Principal, folder_id: int) -> dict:
found = folder(session, principal, folder_id, write=True)
# Prüfsumme für Browser, deren Dateiangaben beim erneuten Wählen veraltet sein können (Safari)
rows = session.execute(select(DocFile.path, DocFile.size, DocFile.modified, DocFile.sha256)
.where(DocFile.folder_id == found.id))
return {"files": {path: [size, modified, sha] for path, size, modified, sha in rows},
"suffixes": sorted(TEXT | EXTRACTED)}
def put(session: Session, principal: Principal, settings: Settings, folder_id: int, path: str, modified: int,
stream: BinaryIO) -> DocFile:
"""Eine Datei hinzufügen oder ersetzen. Gleicher Inhalt (Prüfsumme) wird nicht erneut ausgelesen."""
from app.uploads import service as uploads
found = folder(session, principal, folder_id, write=True)
path = check_path(path)
if not allowed(path):
raise Invalid(f"{path}: Format wird nicht abgeglichen.")
limit = settings.doc_max_mb * 1024 * 1024
digest, size, data = hashlib.sha256(), 0, bytearray()
while chunk := stream.read(CHUNK):
size += len(chunk)
if size > limit:
raise Invalid(f"{path}: größer als {settings.doc_max_mb} MB.")
digest.update(chunk)
data += chunk
sha = digest.hexdigest()
file = session.scalar(select(DocFile).where(DocFile.folder_id == found.id, DocFile.path == path)
.with_for_update())
if file is not None and file.sha256 == sha and file.status != ERROR:
file.modified, file.size = int(modified), size
return file
if file is None:
count = session.scalar(select(func.count()).select_from(DocFile).where(DocFile.folder_id == found.id)) or 0
if count >= settings.doc_max_files:
raise Invalid(f"Höchstens {settings.doc_max_files} Dateien je Ordner.")
workspace = workspaces.get(session, principal, found.workspace_id).workspace
if uploads.storage(session, settings, workspace).free <= 0:
raise Invalid(uploads.QUOTA_FULL)
file = DocFile(folder_id=found.id, path=path, size=size, modified=int(modified), sha256=sha)
session.add(file)
file.size, file.modified, file.sha256, file.error, file.updated = size, int(modified), sha, "", now()
if suffix(path) in TEXT:
file.text, file.status = cut(bytes(data).decode("utf-8", "replace"), settings), OK
else:
file.text, file.status = "", PENDING
session.flush()
target = pending_path(settings, file)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(bytes(data))
return file
def prune(session: Session, principal: Principal, settings: Settings, folder_id: int, paths: list[str]) -> int:
"""Abgleich abschließen: Dateien, die es im Ordner nicht mehr gibt, entfernen."""
found = folder(session, principal, folder_id, write=True)
keep = {check_path(p) for p in paths if p}
removed = 0
for file in session.scalars(select(DocFile).where(DocFile.folder_id == found.id)):
if file.path not in keep:
pending_path(settings, file).unlink(missing_ok=True)
session.delete(file)
removed += 1
found.synced = now()
audit.record(session, "documents.synced", user_id=principal.user_id, workspace_id=found.workspace_id,
target_kind="doc_folder", target_id=found.id, files=len(keep), removed=removed)
return removed
def cut(text: str, settings: Settings) -> str:
limit = settings.doc_max_text_kb * 1024
text = text.replace("\0", "")
return text if len(text.encode()) <= limit else text.encode()[:limit].decode("utf-8", "ignore")
# --- Auslese (Worker) ---
def extractor(session: Session):
"""Aktiver zentraler MCP-Server mit der Rolle „documents“ und festgelegtem Tool, sonst None."""
from app.mcp.models import McpServer
from app.mcp.service import DOCUMENTS
server = session.scalar(select(McpServer).where(McpServer.role == DOCUMENTS, McpServer.active.is_(True),
McpServer.workspace_id.is_(None)).order_by(McpServer.id))
return server if server is not None and (server.config or {}).get("tool") else None
def extract_pending(db: Database, settings: Settings, limit: int = BATCH) -> int:
"""Wartende Dateien über den MCP-Server auslesen. Ohne Server bleiben sie mit Hinweis liegen."""
from app.mcp import client
from app.mcp import service as mcp
done = 0
vault = Vault(settings.secret_key)
for _ in range(limit):
with db.session() as session:
file = session.scalar(select(DocFile).where(DocFile.status == PENDING).order_by(DocFile.id)
.limit(1).with_for_update(skip_locked=True))
if file is None:
return done
raw = pending_path(settings, file)
server = extractor(session)
if server is None:
file.status, file.error = UNSUPPORTED, ("Keine Dokumenten-Auslese eingerichtet (Plattform-Admin: "
"MCP-Server mit der Art „Dokumenten-Auslese“).")
raw.unlink(missing_ok=True)
continue
if not raw.is_file():
file.status, file.error = ERROR, "Original fehlt; bitte erneut abgleichen."
continue
config = dict(server.config)
target = dataclasses.replace(mcp.target(server, vault, settings), timeout=settings.doc_extract_timeout_seconds)
arguments = {config.get("content") or "content": base64.b64encode(raw.read_bytes()).decode()}
if config.get("filename"):
arguments[config["filename"]] = PurePosixPath(file.path).name
file_id, tool = file.id, config["tool"]
# Aufruf ohne offene Transaktion: die Auslese kann dauern
try:
text, error = client.call(target, tool, arguments), ""
except ToolError as e:
text, error = "", str(e)[:500]
except Exception as e:
text, error = "", f"Dokumenten-Auslese nicht erreichbar ({client.describe(e)})."
with db.session() as session:
file = session.get(DocFile, file_id)
if file is None or file.status != PENDING:
continue # inzwischen ersetzt oder gelöscht
if error:
file.status, file.error = ERROR, error
log.warning("Auslese fehlgeschlagen", extra={"event": "doc_extract_failed", "file_id": file_id})
else:
file.status, file.text, file.error = OK, cut(text, settings), ""
file.updated = now()
pending_path(settings, file).unlink(missing_ok=True)
done += 1
return done