--- source_sha: "1555844ea407" --- # Dateiverarbeitung { #file-processing } Dieses Dokument beschreibt, wie Dateien in zwei Zusammenhängen behandelt werden: Datei-Uploads im Chat, die der Person gehören, die sie gemacht hat, und die Ingestion von RAG-Dokumenten, die zu einer Collection gehört und daran gebunden Beim Löschen einer Basis werden die Sync-Quellen getrennt und deaktiviert, deren Ziel diese Basis war. Ihre Connector-Konfiguration bleibt erhalten; vor dem nächsten Sync ist eine ausdrückliche Neuzuordnung nötig. Eine gleichnamige Basis übernimmt sie nicht. Beim Leeren der Standardbasis bleiben die Quellen erhalten, weil die Zielzeile bestehen bleibt. Der CLI-Import verweigert Namen, die bis zur Bereinigung für einen ausstehenden Drop reserviert sind. ist, [wer sie erreichen darf](#who-may-reach-a-collection). ## Datei-Uploads im Chat { #chat-file-uploads } Wenn jemand im Chat eine Datei hochlädt, läuft die folgende Pipeline: ### Ablauf { #flow } ```mermaid flowchart TD U["Upload
POST /api/v1/files/upload"] --> V["Validate
MIME against the allowed list, size limit"] V --> C["Classify
image · pdf · docx · spreadsheet · document · presentation · email · text"] C --> P["Parse
extract text — images skip this"] P --> S["Store
media/{user_id}/"] S --> R["Record
a ChatFile row"] R --> L["Link
attached to the message by message_id"] L --> D["Display
a card per attachment: name, excerpt, type, size"] ``` Die Antwort auf den Upload trägt eine `preview` — die ersten drei Zeilen des extrahierten Textes, begrenzt auf 240 Zeichen — damit die Karte zeigen kann, was *in* der Datei steht, und nicht nur, wie sie heißt. Der Browser kann das nicht selbst herleiten: ein PDF ist Bytes, bis dieser Dienst es geparst hat, und der Client hält eine Id und einen Dateinamen, sobald der Upload geantwortet hat. Sie ist `null` für ein Bild und für eine Datei, die kein Parser lesen konnte, und eine Karte ohne Auszug zeigt ihr Vorschaubild oder allein ihren Namen. ### Die blockierende Arbeit läuft außerhalb des Request-Loops, auf einem eigenen Pool { #the-blocking-work-runs-off-the-request-loop-on-its-own-pool } Einen Upload zu parsen — PyMuPDF über jede Seite, openpyxl über jede Zelle, ein Dekodieren der ganzen Datei — und seine Bytes zu lesen oder zu schreiben, ist blockierend und hat keinen Unterbrechungspunkt. Deshalb läuft es auf einem Thread statt auf dem Request-Loop; ein einziger großer Upload würde sonst jede andere Anfrage und jeden Agent-Stream auf dem Worker einfrieren. Sie laufen auf einem **dedizierten, begrenzten** Pool (`app/core/blocking.py`, dimensioniert über `FILE_IO_MAX_WORKERS`), nicht auf dem gemeinsamen Default-Executor von `asyncio`. Dieser Executor trägt auch das Passwort-Hashing mit `bcrypt` und das DNS für angeheftete Hosts, und ein Schwall von Uploads darf dort nicht jeden Worker besetzen und Anmeldung und ausgehende Anfragen hinter einem unbegrenzten Rückstau von Upload-Puffern warten lassen ([#1108](https://github.com/vstorm-co/agenticos/issues/1108)). Der Schreibvorgang ist außerdem **abbruchsicher**. Ein Executor kann ein laufendes `write_bytes` nicht unterbrechen, deshalb wartet ein abgebrochener Upload das Schreiben ab und löscht die Datei, die er angelegt hat — der Aufrufer erhält nie einen Speicherpfad, könnte die Waise also weder verzeichnen noch aufräumen. ### Die ganze Seite ist das Ablageziel { #the-whole-page-is-the-drop-target } Eine über den Chat gezogene Datei wird **überall auf ihm** angenommen, nicht auf dem Eingabefeld. Das Eingabefeld war das einzige Ziel, was das Anhängen zu einem Spiel machte, einen wenige Zentimeter hohen Streifen zu treffen — und ihn zu verfehlen war kein Nichts: die Vorgabe des Browsers für eine abgelegte Datei ist, sie zu *öffnen*, also navigierte der Tab von der Unterhaltung weg und von allem, was halb getippt darin stand. Dasselbe `preventDefault`, das die Seite die Datei nehmen lässt, hindert den Browser daran, sie zu nehmen; auf dem Fenster zu lauschen behebt also beide Hälften auf einmal. Während eine Datei über der Seite schwebt, legt sich eine Überlagerung darüber: der Grund unscharf, eine gestrichelte Karte in der Mitte, und die Größengrenze pro Datei darauf geschrieben — ein 60-MB-Video, das *nach* dem Ziehen abgelehnt wird, ist ein Hin und Her, das niemand hätte machen müssen. Sie wird per Portal in den Body gehängt statt vom Eingabefeld aus positioniert, denn `fixed` wird gegen den nächsten transformierten Vorfahren gemessen, und ein einziges `backdrop-blur` auf einem Wrapper darüber würde die Überlagerung still in eine Ecke schrumpfen. Zwei Dinge tut sie bewusst nicht. Ein Ziehen, das etwas **anderes** als Dateien trägt — markierter Text, ein Link, eine der ziehbaren Zeilen der App selbst — wird völlig in Ruhe gelassen, nicht einmal unterbunden. Und nichts wird angenommen, solange das Eingabefeld deaktiviert ist: eine archivierte Unterhaltung, ein Run, der auf eine Freigabe wartet. Dass die Überlagerung nicht erscheint, sagt genau das. ### Ein langer Einfügevorgang ist eine Datei { #a-long-paste-is-a-file } Mehr als **2000 Zeichen** in das Eingabefeld einzufügen lädt den Text als `pasted-.txt` hoch, statt ihn einzusetzen. Das Textfeld bleibt unberührt, die Frage wird also neben das getippt, worum es geht, und das Transkript hält einen Anhang statt einer einzigen riesigen Sprechblase. Die Schwelle ist der ganze Entwurf. Wer einen Absatz einfügt und die Eingabetaste drückt, wollte, dass das die Nachricht *ist*, deshalb liegt sie über allem, was ein Mensch als Frage einfügen würde — grob 350 Wörter — und unter jedem Dokument. Darunter hat sich nichts geändert: der Text landet im Textfeld wie eh und je. Danach ist es ein gewöhnlicher `text/plain`-Anhang, und alles Weitere unten gilt unverändert für ihn, und genau das ist der Punkt: ein Agent mit einem Workspace bekommt das Eingefügte als Datei, die er öffnen kann, und einer ohne bekommt den Text in seinem Prompt. ### Unterstützte Dateitypen { #supported-file-types } | Kategorie | MIME-Typen | Endungen | Verarbeitung | |----------|-----------|------------|------------| | **Bilder** | image/jpeg, image/png, image/webp, image/gif | .jpg, .png, .webp, .gif | Unverändert gespeichert. Als `BinaryContent` an das LLM zur Bildanalyse gesendet. | | **TIFF** | image/tiff | .tiff, .tif | Unverändert gespeichert; erst beim Anzeigen für das Modell in PNG-Seite(n) umgewandelt, begrenzt durch `CHAT_TIFF_MAX_INLINE_PAGES`. Im Browser nicht inline dargestellt — als Download ausgeliefert. | | **PDF** | application/pdf | .pdf | Text über den konfigurierten PDF-Parser extrahiert. Als Kontext an den Prompt angehängt. | | **DOCX** | …wordprocessingml.document | .docx | Absätze über `python-docx` extrahiert. Als Kontext an den Prompt angehängt. | | **DOC** | application/msword | .doc | Über einen verwalteten LibreOffice-Subprozess (`soffice`) in Text umgewandelt. Benötigt LibreOffice im Image; fehlt es, wird der Text als nicht verfügbar gemeldet. | | **Tabelle** | …spreadsheetml.sheet, …ms-excel.sheet.macroEnabled.12, application/vnd.ms-excel, …opendocument.spreadsheet | .xlsx, .xlsm, .xls, .ods | Jedes Blatt gelesen, benannt, Zeilen tabulatorgetrennt — `openpyxl` für OOXML, `xlrd` für das alte `.xls`, `odfpy` für `.ods`. Als Kontext an den Prompt angehängt. | | **Präsentation** | …presentationml.presentation, …opendocument.presentation | .pptx, .odp | Text aus Formen, Tabellenzellen und Foliennotizen über `python-pptx` (`.pptx`) oder `odfpy` (`.odp`). Als Kontext an den Prompt angehängt. | | **Dokument (OpenDocument)** | …opendocument.text | .odt | Absätze über `odfpy`. Als Kontext an den Prompt angehängt. | | **E-Mail** | application/vnd.ms-outlook | .msg | Kopfzeilen und Text aus den OLE/MAPI-Streams über `olefile` (BSD) gelesen; eingebettete Anhänge werden nur namentlich aufgelistet, nicht rekursiv extrahiert. Als Kontext an den Prompt angehängt. | | **Text** | text/plain, text/markdown, text/csv, text/html, text/xml, application/xml, application/json | .txt, .md, .csv, .html, .xml, .json | Direkt als UTF-8 dekodiert (XML nach seiner deklarierten BOM/Kodierung). Als Kontext an den Prompt angehängt. | | **Archiv** | application/zip, application/x-zip-compressed, application/gzip, application/x-gzip | .zip, .tar.gz, .tgz | Beim Hochladen vollständig gelesen und abgelehnt, wenn es unsicher ist (siehe [Archive](#archives)). Sein Text ist die Liste dessen, was es enthält. Wird in den Workspace entpackt; ein Agent ohne Workspace lehnt es ab. | ### Wohin ein Anhang geht, hängt vom Agent ab { #where-an-attachment-goes-depends-on-the-agent } Die Spalte "an den Prompt angehängt" oben beschreibt, was mit einem Agent **ohne Workspace** geschieht, und zwar mit der ganzen Datei, in jeder Runde. Ein zweihundertseitiger Bericht kostet sein volles Token-Gewicht, wenn die erste Frage gestellt wird, und noch einmal bei "und wie war es im März"; eine fünfzig Megabyte große CSV lässt sich gar nicht anhängen. Ein Agent mit der [Capability `sandbox`](reference/capabilities.md#files-shell) bekommt die Datei statt des Textes: | Anhang | Ohne Workspace | Mit einem Workspace | |---|---|---| | text, csv, md, json, xml | geparster Text inline eingefügt | nach `uploads/` geschrieben, die Nachricht trägt eine Referenz und die ersten 20 Zeilen | | pdf, docx, spreadsheet, document, presentation, email | geparster Text inline eingefügt | nach `uploads/` geschrieben, mit dem extrahierten Text daneben, sofern die Laufzeitumgebung sie nicht selbst lesen kann (`email` bekommt ihn immer — `lit` kann `.msg` nicht lesen); Referenz und die ersten 20 Zeilen | | Bild | `BinaryContent` | `BinaryContent` **und** geschrieben; die Referenz nennt den Pfad | | tiff | PNG-Seite(n) als `BinaryContent`, Seitengrenze vermerkt | Original-`.tiff` geschrieben; PNG-Seite(n) angezeigt, Seitengrenze vermerkt | | Archiv (`.zip`, `.tar.gz`) | abgelehnt: dem Modell wird gesagt, dass der Agent keinen Workspace hat, in den er es entpacken könnte, und nichts davon kommt in den Prompt | nach `uploads/-/` entpackt; der Verweis nennt den Ordner und den Anfang des Baums | ### Archive { #archives } Ein `.zip` oder `.tar.gz` ist der Weg, einem Agent ein Repository oder einen beliebigen Ordner mit Dateien zu geben. Die Plattform entpackt es auf dem Server, bevor der Agent läuft, statt es dem Agent zum Entpacken zu übergeben. Ein gespeicherter Workspace hat keine Shell, in der `unzip` laufen könnte, ein Sandbox-Image hat es nicht sicher, und ein Archiv ist ein bekannter Weg, außerhalb eines Ordners zu schreiben. Eine Prüfung auf dem Server lässt sich testen; ein Agent, der `tar -xf` auf das Erhaltene ausführt, nicht. Das Archiv wird beim Hochladen vollständig gelesen. Das Hochladen wird mit 400 abgelehnt und nichts gespeichert, wenn: - der Pfad eines Members aus dem Archiv herausführt (`../`), an der Wurzel (`/etc/x`) oder an einem Laufwerk (`C:/`) beginnt, einen Backslash oder ein Steuerzeichen enthält; - ein Member ein symbolischer Link, ein Hardlink, eine Gerätedatei oder ein FIFO ist; - derselbe Pfad zweimal vorkommt oder zugleich als Datei und als Ordner; - ein Member verschlüsselt ist oder die Datei nicht das Archiv ist, das ihr Name sagt; - es mehr als `WORKSPACE_ARCHIVE_MAX_ENTRIES` Dateien und Ordner enthält (5000), auf mehr als `WORKSPACE_ARCHIVE_MAX_BYTES` (200 MiB) entpackt oder auf mehr als `WORKSPACE_ARCHIVE_MAX_RATIO`-mal seine eigene Größe (100) — eine Dekompressionsbombe. Größen werden beim Lesen der Members gemessen, nicht aus den Kopfdaten des Archivs übernommen, die falsch sein können; - eine Datei darin auf mehr als `WORKSPACE_ARCHIVE_MAX_MEMBER_BYTES` (50 MiB) entpackt, denn beim Entpacken liegt immer eine Datei auf einmal im Speicher. Die Upload-Grenze `CHAT_MAX_UPLOAD_SIZE_MB` gilt für das Archiv selbst. Für einen Agent mit Workspace werden die Members unter `uploads/-/` geschrieben, ihre Ordner bleiben erhalten — `repo.zip` mit `repo/src/app.py` wird zu `uploads/3f2a1b9c-repo/repo/src/app.py`. Der Verweis nennt diesen Ordner und beginnt mit den ersten Zeilen der Member-Liste. Die Members werden einzeln geschrieben, sodass immer nur einer im Speicher liegt. Lehnt der Workspace einen Schreibvorgang mittendrin ab oder wird der Turn während des Entpackens gestoppt, wird der Ordner entfernt: Dem Modell wird gesagt, dass das Archiv nicht geschrieben werden konnte, und ein halb entpackter Baum wird nie für einen ganzen gehalten. Ein Agent ohne Workspace hat keinen Ort zum Entpacken. Dem Modell wird gesagt, dass es das Archiv nicht öffnen kann, und es bekommt die Member-Liste nicht, denn eine Liste von Pfaden ließe es Fragen zu Code beantworten, den es nicht lesen kann. Um Dateien wieder herauszuholen, bietet der Bereich **Files** im Chat jeden Ordner und den ganzen Workspace als `.zip` an — siehe [Sandbox: einen Ordner herunterladen](sandbox.md#downloading-a-folder-as-a-zip). **Ein späterer Zug hat die Datei noch.** Was der Anhang dem Prompt hinzugefügt hat - der eingefügte Text oder der Verweis mit seinen ersten Zeilen - wird neben der Nachricht gespeichert und mit dem Verlauf wiedergegeben, sodass „und was ist mit März“ ein Modell erreicht, das noch von der Datei weiß. Ein Bild wird in diesem Verlauf nur genannt, nicht erneut gesendet; ein Agent, der es noch einmal ansehen muss, bittet darum oder öffnet es aus `uploads/`, wenn er einen Workspace hat. **Eine Datei aus einem abgebrochenen Zug legt der nächste Zug zurück.** Eine Datei wird vor dem Start des Runs an die Nachricht gebunden, mit der sie kam, und kann deshalb nicht erneut angehängt werden. Endete der Run zu dieser Nachricht, ohne abzuschließen — mit einem Fehler, abgebrochen oder gestoppt durch ein Budget oder den Schleifenwächter —, schreibt der nächste Zug desselben Agenten für diese Person in dieser Unterhaltung die Datei zurück nach `uploads/` (mit ihrem extrahierten Text, wo der Workspace das Original nicht lesen kann, und nur den Text, wenn nur er fehlt), bevor er den Workspace zum ersten Mal nutzt: sofort bei einem gespeicherten Workspace, beim ersten Zugriff bei einer Sandbox. Der Run wird angewiesen, `uploads/` aufzulisten, bevor er sagt, dass eine Datei fehlt, und zu sagen, dass sie nicht zurückgelegt werden konnte, wenn sie dann noch fehlt. Zurückgelegt werden nur die eigenen Dateien der Person, nur aus abgebrochenen Zügen dieses Agenten, höchstens die 50 neuesten, und nur, bis dieser Agent für dieselbe Person einen Zug abschließt, der nach dem Ende des abgebrochenen begann und frühere Anhänge im Workspace tatsächlich vorbereitet hat. Ein abgeschlossener Zug ohne Workspace oder ohne Sandbox-Nutzung lässt dieses Zeitfenster offen. Ein Zug mit einem API-Schlüssel, für einen Widget-Besucher oder von jemand anderem aus einer Freigabe entlassen beendet diesen Zeitraum nicht. Eine danach entfernte Datei bleibt entfernt. Manche Dateien kommen nicht zurück: die eines Zugs, den eine Guardrail blockiert hat, weil die Eingabe-Guardrail abgelehnt hat, was er mitbrachte; die eines Prompts, dessen Run nie geöffnet wurde und der keinem Agenten gehört; Dateien eines Zugs mit einem API-Schlüssel oder für einen Widget-Besucher. Hat der Agent eine Eingabe-Guardrail, wird jede Datei vor dem Zurücklegen auch daraufhin geprüft, was die Guardrail von ihr lesen würde: ihren Namen, ihren Pfad und ihre ersten Zeilen. Ein Zug kann stoppen, bevor die Guardrail seine Eingabe liest, etwa an einem Budget. Eine Datei, die die Guardrail ablehnt, wird daher nicht geschrieben, und das Log nennt nur ihre ID. Werden alle Dateien abgelehnt, erfährt der Run nicht, dass eine zurück sein könnte. Ohne Eingabe-Guardrail ändert sich nichts. **Der extrahierte Text kommt nur dort mit, wo nichts das Original lesen kann.** Eine `.txt` des Parsens wurde früher neben jedes PDF, jede `.docx` und jede Tabelle geschrieben, mit der Begründung, dass eine Shell für keines davon eine Bibliothek hat — `read_file` auf einer `.xlsx` liefert Buchstabensalat, und `run_python` hat überhaupt kein Dateisystem. Auf einer Laufzeitumgebung mit `lit` ist diese Begründung überholt: `lit parse q3.xlsx -o q3.md` ist ein Befehl, mit OCR für einen Scan und LibreOffice für die Altformate (`sandbox.md`), das Geschwister ist also eine zweite Kopie des Dateiinhalts auf der Platte, um einen Tool-Aufruf zu sparen. Überall sonst wird sie weiterhin geschrieben, und das Kriterium ist nicht die Art des Backends: ein `state`-Workspace ist nur Dateien ganz ohne Shell, eine Daytona-Sandbox und die eigene Laufzeitumgebung eines Deployments tragen, was ihr Image trägt, und keine davon hat `lit`. Die Bedingung ist, ob dieses Deployment die Laufzeitumgebung dem Modell *beschrieben* hat — dieselbe Einweisung, die der Run an seine Instruktionen anhängt. Ist gesagt, dass es `lit` gibt, kein Geschwister; sonst geht der Text neben die Datei. Das Parsen geschieht so oder so serverseitig, denn der *Text* ist das, was ein Agent ohne Workspace bekommt und woraus die 20 Zeilen in der Nachricht stammen. Den Upload ohne Parsen anzunehmen würde einen Agent mit Workspace als unlesbare Bytes erreichen und einen ohne Workspace als gar nichts. **Einschränkungen.** Ein gescanntes PDF oder TIFF liefert ein Bild, keinen OCR-Text — der Chat-Pfad hat keine OCR (die Wissensdatenbank schon, über LiteParse). Tabellen in Office- und DOCX-Dateien werden zu tabulator- oder zeilengetrenntem Text abgeflacht. DOC benötigt LibreOffice im Image; ohne wird der Text als nicht verfügbar gemeldet. Eingebettete Anhänge einer `.msg` werden nur namentlich aufgelistet, nicht extrahiert. Ein mehrseitiges TIFF zeigt dem Modell bis zu `CHAT_TIFF_MAX_INLINE_PAGES` Seiten; ein Agent mit Workspace öffnet den Rest aus dem Original auf der Festplatte. Der extrahierte Text ist auf `CHAT_PARSED_TEXT_MAX_CHARS` begrenzt, und Budgets pro Datei und pro Runde begrenzen, was einem Agent ohne Workspace eingefügt wird. **Die Grenze ist ein Budget, das die Parser verbrauchen, kein Schnitt nach dem Parsen.** Ein PDF wird Seite für Seite geladen und stoppt an der Seite, die die Grenze überschreitet, oder nach 2.000 Seiten. Eine `.xlsx` liest ihre Zeilen als Stream, stoppt an der Zeile und liest höchstens eine Million Zellen, sodass ein Blatt, das einen riesigen leeren Bereich deklariert, nichts zusätzlich kostet, und eine Mappe, die diese Zahl stoppt, endet mit einem Hinweis darauf. DOCX, PPTX, ODT und ODP hören am Absatz oder an der Folie auf, Text zu extrahieren, aber ihre Bibliotheken entpacken und parsen zuerst die ganze Datei, innerhalb der Größengrenzen für Archive. Klartext, `.msg` und `.doc` werden ganz gelesen. Text, der die Grenze überschreitet, endet mit einer Markierung, die sagt, dass der Rest des Dokuments weggelassen wurde. **Ein abgelehnter Schreibvorgang wird einmal gesagt, über den Workspace.** Ein Run, dessen Workspace eine Datei nicht annimmt, ist ein Run, dessen Shell- und Datei-Tools ebenfalls scheitern werden, und eine Zeile pro Datei kann das nicht sagen: eine Runde las jeden Fehlschlag als Problem mit dem Befehl, den sie gerade geschrieben hatte, und versuchte es weiter — `ls`, dann ein `curl` auf eine `data:`-URI, dann drei Behelfslösungen, der Person angeboten, über zwei Runden hinweg. Ein Satz sagt jetzt, dass der Workspace nicht verfügbar ist und dass ein weiterer Versuch auf dieselbe Weise scheitern wird. **Ein Zug, den der Eingabe-Guardrail blockiert, schreibt nichts.** Der Verweis und sein Kopf werden von der Eingabekante des Agents geprüft, bevor die Datei geschrieben wird. Eine Datei aus einem blockierten Zug erreicht `uploads/` also nie, und ein späterer Zug kann sie nicht öffnen. Siehe [Schutzregeln](reference/capabilities.md#guardrails). Die Referenz ist das, was das Modell tatsächlich liest: ``` Attached file: raport.csv (/uploads/3f2a1b9c-raport.csv, 2.4 MB, text) First 20 lines: month,total jan,10 ... ``` Genug, um einen Vertriebsexport von einem Log zu unterscheiden und die Spaltennamen zu sehen — und genau das braucht das Modell, um zu entscheiden, ob sich ein Tool-Aufruf für den Rest lohnt. Die Datei hat aufgehört, Kontext zu sein, und ist zu Daten geworden. Vier Dinge daran sind Absicht: - **Bilder gehen beide Wege.** Das Modell muss das Bild weiterhin *sehen* — dafür ist ein multimodales Modell da, und eine Pfadangabe ist kein Ersatz — und es muss es auch verkleinern oder zuschneiden können, wofür es Bytes auf einem Dateisystem braucht. Oberhalb von `SANDBOX_INLINE_IMAGE_MAX_BYTES` wird nur die Datei behalten, denn ab diesem Punkt lohnt es sich nicht mehr, die Bytes zweimal zu bezahlen. - **Ein PDF bekommt beide Hälften.** Die Bytes sind das, worum jemand gebeten hat; der Text, den diese Plattform ohnehin extrahiert hat, ist die Hälfte, die eine Shell tatsächlich lesen kann. - **Dieselbe Datei wird einmal geschrieben.** Der Pfad wird aus der `ChatFile`-Id abgeleitet, sie in Runde fünf erneut anzuhängen löst sich also auf den Pfad auf, den sie schon hat — ein Upload kostet einen Schreibvorgang, nicht einen pro Runde für den Rest der Unterhaltung. - **Dem Dateinamen wird nicht vertraut.** Aus `../../etc/passwd` wird `etc_passwd`; zwei Dateien namens `report.csv` können einander nicht überschreiben. Eine Datei, die nicht gespeichert werden kann — ein voller Workspace — fällt auf den Inline-Weg zurück, statt zu verschwinden, und eine, die der Dateispeicher nicht laden kann, wird übersprungen, statt die Runde scheitern zu lassen: die Person hat eine Frage gestellt, und ohne den Anhang zu antworten ist besser, als nicht zu antworten. Das Routing geschieht in `app/services/attachments.py`, aufgerufen vom Chat-Runner statt von jeder Oberfläche einzeln. Es muss dort liegen: wohin eine Datei geht, hängt davon ab, ob der Agent einen Workspace hat, und das entscheidet `prepare`, das noch nicht gelaufen ist, wenn eine Oberfläche ihren Prompt zusammenbaut. ### PDF-Parsing (Chat) { #pdf-parsing-chat } Chat-Anhänge werden mit **PyMuPDF** gelesen, und das ist nicht konfigurierbar. Ein Anhang gehört zu keiner Collection, es gibt also keine gespeicherte Konfiguration, aus der eine Parser-Wahl gelesen werden könnte. NUL-Zeichen werden vor dem Speichern aus dem extrahierten Text entfernt, damit PostgreSQL den Upload annehmen kann. Die Originalbytes der PDF-Datei bleiben erhalten. Die Variable `CHAT_PDF_PARSER`, die früher zwischen drei Parsern wählte, gibt es nicht mehr. Beide Alternativen waren in `except Exception: return self._parse_pdf_pymupdf(data)` gehüllt, ein Deployment, das sie auf `llamaparse` oder `liteparse` setzte, benutzte also stillschweigend ohnehin PyMuPDF — und der LiteParse-Zweig hätte überhaupt nicht funktionieren können, denn er rief eine Methode `parse_async` auf, die die Anbindung nicht definiert. ### Größengrenzen { #size-limits } !!! warning "Zwei Obergrenzen, und der Browser hat seine eigene Kopie der einen" Ein Chat-Anhang wird von `CHAT_MAX_UPLOAD_SIZE_MB` (10 MB) abgelehnt, ein Dokument der Wissensdatenbank von `MAX_UPLOAD_SIZE_MB` (50 MB). Der Frontend-Container liest dasselbe `CHAT_MAX_UPLOAD_SIZE_MB` zur Laufzeit, geben Sie also beiden Containern einen Wert: zu hoch auf der Browser-Seite und das Eingabefeld nimmt eine Datei an, die die API ablehnt, zu niedrig und es lehnt eine ab, die die API genommen hätte. - Maximale Anhangsgröße: `CHAT_MAX_UPLOAD_SIZE_MB` (Vorgabe: **10 MB**). Das ist die eigene Grenze dieses Abschnitts — ein Chat-Anhang wird von dieser Zahl abgelehnt, nicht vom größeren `MAX_UPLOAD_SIZE_MB` der Wissensdatenbank, und die beiden sind getrennte Einstellungen, weil ein Anhang an einen Agent ohne Workspace ganz in den Prompt eingefügt wird, während ein Dokument der Wissensdatenbank gechunkt und eingebettet wird. - Ein Dokument der Wissensdatenbank ist stattdessen durch `MAX_UPLOAD_SIZE_MB` gedeckelt (Vorgabe: **50 MB**). - Der ganze Anfragekörper ist über beiden gedeckelt, beim größeren der beiden plus einem Zuschlag für Multipart, das Anheben einer der Obergrenzen hebt also diese mit an. - Die Grenze wird serverseitig durchgesetzt, nachdem der Dateiinhalt gelesen wurde. Die eigene Prüfung des Browsers liest dasselbe `CHAT_MAX_UPLOAD_SIZE_MB` aus der Umgebung des Frontend-Containers, den beiden Containern sollte also ein Wert gegeben werden: zu hoch und das Eingabefeld nimmt eine Datei an, die die API ablehnt, zu niedrig und es lehnt eine ab, die die API genommen hätte. ### Speicherung { #storage } Jede hochgeladene Datei — ein Chat-Anhang, ein Avatar, das Zeichen des Deployments, das Original eines Wissensdatenbank-Dokuments — geht durch ein Speicher-Backend, gewählt über `FILE_STORAGE_BACKEND` zur Deployment-Zeit und nie pro Organisation. Welches es auch ist, eine Zeile hält denselben **Speicherpfad**: `{owner}/{uuid}_{filename}`. `local`, die Voreinstellung, schreibt sie unter `MEDIA_DIR`: ``` media/ {user_id}/ a1b2c3d4e5f6_document.pdf f6e5d4c3b2a1_screenshot.png ... ``` `s3` schreibt dieselben Pfade als Objekt-Keys in einen S3-kompatiblen Bucket, unter `FILE_STORAGE_S3_PREFIX`, und bittet den Speicher, jeden davon zu verschlüsseln — SSE-S3 als Vorgabe, SSE-KMS mit einem Schlüssel, den das Deployment benennt. Die Einstellungen stehen in der [Konfiguration](configuration.md#uploaded-files-at-rest). Bei lokalem Speicher müssen API und Worker für die Wissensbasis-Ingestion weiterhin `MEDIA_DIR/_rag_tmp` teilen. Mit S3 lädt jede Worker-Ausführung eine eigene Kopie des gespeicherten Objekts und entfernt sie bei Abschluss, Parserfehler oder Abbruch. Pods teilen kein API-Scratch-Volume; siehe [Kubernetes-Deployment](kubernetes.md). Ein getöteter Kindprozess kann bis zum Ersetzen des Pods eine temporäre Kopie hinterlassen. !!! info "Welches Backend, und was jedes von Ihnen verlangt" Lokal ist die ehrliche Antwort für einen einzelnen Host: verschlüsseln Sie das Volume, und die Dateien sind so geschützt wie die Platte. Es hört auf, eine zu sein, bei der zweiten API-Replik — zwei Container, zwei Platten, und eine in die eine hochgeladene Datei ist auf der anderen ein 404 — und wenn ein Kunde seine Dateien unter einem Schlüssel will, den er kontrolliert. Ein Backend-Wechsel verschiebt nicht, was das andere bereits hält, und hier migriert das nichts. Es ist eine Entscheidung beim Aufsetzen des Deployments; ein späterer Wechsel braucht die Dateien von Hand kopiert, und die Pfade sind auf beiden Seiten gleich, also genügt eine Kopie. Agent-Workspaces sind in keinem der beiden Backends. Ein `state`-Workspace lebt in dieser Datenbank und ein `docker`-Workspace im Speicher des Sandbox-Hosts, ein Objektspeicher ändert also nichts daran, wo sie sind — siehe [die Sandbox](sandbox.md). `agenticos cmd doctor` gibt aus, welches Backend ein laufendes Deployment nutzt und ob die Verschlüsselung an ist. ### Mail-Anhänge in einem Workflow { #mail-attachments-in-a-workflow } Ein [Workflow](workflows.md#from-an-email), den eine Gmail- oder Outlook-Nachricht gestartet hat, speichert deren Anhänge als Dateien dieses Runs, innerhalb des Runs und nie im Poll, der das Postfach gelesen hat. Für sie gelten die Liste erlaubter Dateitypen und das Größenlimit für Chat-Uploads: Von den Anhängen, die ein Postfach liefert, werden nur die ersten 10 pro Nachricht berücksichtigt, und 25 MiB pro Nachricht. Ein ausgelassener Anhang wird mit dem Grund aufgeführt, und ein Testlauf holt nichts ab. Ein `.zip` oder `.tar.gz` wird als `type_not_allowed` ausgelassen: Ein Chat prüft ein Archiv Member für Member, bevor er es entpackt (siehe [Archive](#archives)), und ein Archiv, das ein Workflow erhält, prüft nichts. Eine HTML-Datei in einem Workflow, etwa eine gespeicherte Seite oder ein Anhang, wird von [`html.parse`](reference/workflow-nodes.md#html-parse) gelesen, nicht von `text.extract`, das Dokumente liest und das Markup als Text zurückgeben würde. ### Das Modell ChatFile { #chatfile-model } Das Datenbankmodell `ChatFile` verfolgt hochgeladene Dateien: | Feld | Typ | Beschreibung | |-------|------|-------------| | `id` | UUID | Primärschlüssel | | `user_id` | UUID/FK | Eigentümer (für die Zugriffskontrolle verwendet) | | `filename` | String | Ursprünglicher Dateiname | | `mime_type` | String | Aufgelöster (kanonischer) MIME-Typ — eine `application/octet-stream`-`.tiff` wird als `image/tiff` gespeichert, damit der Download- und der Inline-Umwandlungspfad ein verlässliches Feld lesen. Vor FA-013 hochgeladene Zeilen behalten ihren deklarierten Typ; die Leser tolerieren beides. | | `size` | Integer | Dateigröße in Bytes | | `storage_path` | String | Relativer Pfad im Speicher | | `organizational_unit` | Zu welchem Teil der Organisation dieses Dokument gehört — aus dem Upload oder von der Sync-Quelle geerbt. `null`, wenn es niemand abgelegt hat | | `file_type` | String | Klassifizierter Typ: `image`, `pdf`, `docx`, `spreadsheet`, `document`, `presentation`, `email`, `text` | | `parsed_content` | Text | Extrahierter Textinhalt (NULL bei Bildern) | | `message_id` | UUID/FK | Verknüpfte Nachricht (gesetzt beim Senden der Nachricht) | | `created_at` | DateTime | Zeitpunkt des Uploads | ### Eigentum und Zugriff { #ownership-access } - Nur der Eigentümer einer Datei kann seine Dateien herunterladen (`GET /files/{id}`). - Die Methode `FileUploadService.get_user_file()` vergleicht `chat_file.user_id` mit der Id des anfragenden Nutzers. Bei Nichtübereinstimmung liefert sie `NotFoundError`. - **Eigentum ist die ganze Regel, und nichts weitet sie.** Keine Berechtigung, keine Rolle in einer Organisation und kein Grant erreicht über diese API die Chat-Datei einer anderen Person — anders als eine Collection, die ein Grant öffnen kann. Verglichen wird gegen `user_id`, und es gibt keinen zweiten Zweig, der einen weiteren Fall halten könnte. - **Der Verknüpfungsschritt nimmt dieselbe Regel.** Eine Nachricht hängt nur die eigenen *nicht verknüpften* Dateien der absendenden Person an: eine Id, die die Datei eines anderen Nutzers nennt, oder eine, die bereits an einer Nachricht hängt, wird abgelehnt statt still angewendet — eine Runde kann also weder den Dateinamen einer fremden Person darstellen noch einen Anhang von der Nachricht ziehen, an der er schon hängt. ## Ingestion von RAG-Dokumenten { #rag-document-ingestion } Wenn Dokumente in die RAG-Wissensdatenbank eingelesen werden (über CLI oder API), übernimmt eine andere Pipeline das Parsen, Chunken und Einbetten. ### Ablauf der Ingestion { #ingestion-flow } ```mermaid flowchart TD I["Input
a path (CLI) or an upload (API)"] --> P["Parse
DocumentProcessor picks a parser by type"] P --> C["Chunk
size, overlap and strategy are configurable"] C --> E["Embed
through the collection's provider"] E --> S["Store
vectors in rag_<collection>"] S --> T["Track
a RAGDocument row carries the status"] ``` !!! note "Über die API ist die Reihenfolge umgekehrt" Die `RAGDocument`-Zeile wird **zuerst** geschrieben, und die vier mittleren Schritte laufen in einer Hintergrundaufgabe mit einer eigenen Session - und deshalb antwortet ein Upload mit `202 {"status": "processing"}`, statt zu warten. Gleichzeitige erste Schreibvorgänge in dieselbe Sammlung warten darauf, dass ihre Tabelle und Indizes in einer Transaktion erstellt werden. Schlägt dies fehl, wird die Transaktion zurückgerollt und ein wartender Schreibvorgang kann die Tabelle erstellen. Es gibt zwei Adressen, an denen ein Upload ankommen kann — `POST /rag/collections/{name}/ingest` und `POST /kb/{kb_id}/documents` — und beide antworten mit **202** und derselben `RAGIngestResponse`, mit jedem Feld davon, `"document_id": null` eingeschlossen. Die Id des Dokuments im Vektorspeicher existiert nicht, bevor der Worker es indexiert hat. Eine der beiden ließ den Schlüssel früher weg, statt ihn null zu senden, ein Client, der die Antwort normalisierte, bekam also von jeder eine andere Form ([#560](https://github.com/vstorm-co/agenticos/issues/560)). **Auf `complete` warten, nicht auf Vektoren.** `GET /rag/collections/{name}/info` leitet `indexing_status` aus den verfolgten Dokumenten der Collection ab: `indexing`, solange eines davon noch `processing` ist - in der Warteschlange oder in der Ingestion - und `complete`, sobald keines mehr es ist. `processing_count`, `indexed_count` und `failed_count` geben an, wie viele Dokumente in welchem Zustand sind. Ein fehlgeschlagenes Dokument befindet sich in einem Endzustand und hält die Collection deshalb nicht auf `indexing`; es erscheint in `failed_count`. Das Feld war früher die Konstante `complete`, und `total_vectors` wächst, während der Worker einen Stapel abarbeitet - ein Skript, das auf eines von beiden wartete, fragte eine halb aufgebaute Collection ab ([#2486](https://github.com/vstorm-co/agenticos/issues/2486)). `GET /kb/{kb_id}/ingestion` beantwortet dasselbe für eine Wissensdatenbank und zählt die Dokumente, die ihre Seite auflistet, statt alles unter ihrem Collection-Namen, den eine andere Wissensdatenbank teilen kann; die Seite der Wissensdatenbank zeigt diesen Zustand neben der Dokumentanzahl. Ein Dokument, das in `processing` hängen bleibt, hält beide Antworten auf `indexing`, bis es gelöscht wird oder, bei einem synchronisierten Dokument, bis seine Quelle erneut synchronisiert. !!! danger "Die Aufgabe wird gestartet, nachdem die Transaktion der Anfrage committet" Sie wird mit `spawn_after_commit` übergeben, **nicht** mit `spawn`, und von der Session selbst gestartet, sobald die Zeile dauerhaft ist. Früher losgeschickt würde sie das Dokument über seine Id suchen, nichts finden und anhalten — und den Upload, den sie bereits bestätigt hatte, für immer in `processing` zurücklassen ([#417](https://github.com/vstorm-co/agenticos/issues/417)). Dasselbe gilt für einen Sync: die `SyncLog`-Zeile existiert vor ihrem Flow. Siehe [Hintergrundarbeit aus einer Anfrage heraus anstoßen](architecture.md#dispatching-background-work-from-a-request). **Jeder Flow baut seine eigene Engine für den Vektorspeicher und verwirft sie mit der Arbeit des Flows.** **Ein Loop besitzt die Pools des Prozesses, und alles andere baut seine eigene Engine.** Die Lifespan der API beansprucht sie beim Start: sie bedient jede Anfrage und verwirft sie beim Herunterfahren, sie ist also der eine Loop, dessen Verbindungen sie zwischenspeichern dürfen. Der Worker beansprucht sie ebenso für seinen einen Loop, und jeder Ingestion-Kindprozess für seinen eigenen. Außerhalb des besitzenden Loops verhält sich `get_db_context` wie `get_worker_db_context` — eine `NullPool`-Engine für den Aufruf, am Ende verworfen — denn es wird aus den Kanal-Loops und aus dem Embedding-Resolver eines Agents erreicht, jeweils auf einem eigenen Loop ([#1079](https://github.com/vstorm-co/agenticos/issues/1079)). Die Wissenssuche eines Agents folgt derselben Regel für ihren Vektorspeicher: der Prozessspeicher auf dem besitzenden Loop und `agent_vector_engine` — ohne Pool — überall sonst. Er ist der eine Vektor-Aufrufer, der nicht wissen kann, auf welchem Loop er ist, und sein Retrieval-Speicher ist für die Lebensdauer des Prozesses zwischengespeichert; ein gepoolter Speicher, den sich zwei Loops in einem Worker teilen, gibt dem zweiten eine Verbindung, die der erste geöffnet hat. Den Pool für die API zu behalten begrenzt das: `NullPool` öffnet eine Verbindung je Entnahme und deckelt nichts, während der Pool bei `DB_POOL_SIZE + DB_MAX_OVERFLOW` in eine Warteschlange geht. ### Unterstützte Formate { #supported-formats } `.txt`, `.md` und `.docx` werden von den eingebauten Python-Parsern gelesen, gleich welchen Parser die Collection hat. Darüber hinaus folgt die Menge dem Parser: | Parser | Liest außerdem | Braucht | |--------|-----------|-------| | PyMuPDF | `.pdf` | nichts | | LiteParse | `.pdf`; Bilder (`.png`, `.jpg`, `.tiff`, `.svg`, …); Office-Formate (`.xlsx`, `.pptx`, `.odt`, `.csv`, `.rtf`, …) | LibreOffice **nur für Office-Formate** — Bilder werden nativ konvertiert | | LlamaParse | `.pdf`, `.pptx`, `.xlsx`, `.csv`, `.rtf`, `.epub`, `.html`, Bilder | Ein LlamaParse-Schlüssel im Vault der Organisation, von der Collection benannt (`llamaparse_secret_id`). Einen Deployment-Schlüssel gibt es nicht | Das Dockerfile des Backends installiert LibreOffice und Tesseract, Office-Formate und OCR funktionieren im Container also von Haus aus. Läuft das Backend außerhalb von Docker, wird ein Office-Upload in eine LiteParse-Collection mit einer Meldung abgelehnt, die LibreOffice nennt, statt während der Konvertierung zu scheitern. `GET /api/v1/rag/supported-formats?parser=liteparse` antwortet für einen Parser. Diese Mengen sind das, was `DocumentProcessor` tatsächlich routen kann — festgehalten von `backend/tests/test_supported_formats.py`, denn früher waren sie Wunschdenken: eine `.xlsx` wurde angenommen, gespeichert, bekam eine Dokumentzeile und wurde losgeschickt, und starb dann im Worker als "Unsupported file type". ### Parser-Wahl (RAG) { #parser-selection-rag } Pro Collection, auf `/rag`, und pro Upload überschreibbar — keine Umgebungsvariable. Gespeichert auf `knowledge_bases.ingestion_config`. | Parser | Am besten für | |--------|----------| | PyMuPDF (Vorgabe) | Schnelle lokale Verarbeitung, textlastige Dokumente; der einzige, der eingebettete Bilder zur Beschreibung extrahiert | | LiteParse | Lokal, ohne Schlüssel, layoutbewusst; liest Office-Formate und Bilder; Markdown-Ausgabe | | LlamaParse | Komplexe Layouts und gescannte PDFs; Cloud, seitenweise abgerechnet | ### LiteParse-Optionen { #liteparse-options } | Einstellung | Vorgabe | Anmerkungen | |---------|---------|-------| | `liteparse_output_format` | `markdown` | Rekonstruiert Überschriften, Tabellen und Listen — genau das, woran die Chunking-Strategie `markdown` trennt. `text` behält das räumliche Raster. | | `auto_ocr` | `true` | Führt LiteParse' günstige Prüfung der Textebene je Dokument aus und setzt OCR nur dort ein, wo es nötig ist. OCR dominiert die Kosten eines Parsevorgangs. | | `ocr_language` | `eng` | Tesseract-Codes — drei Buchstaben, mit `+` verbunden für mehrere (`eng+pol`). Eine Sprache ohne installiertes Paket liest nichts; fügen Sie `tesseract-ocr-` in das Dockerfile ein. | | `liteparse_dpi` | `150` | Höher liest blasse Scans, ist aber langsamer. | | `max_pages` | `1000` | Die Einstellung, die die Kosten eines Dokuments begrenzt; `parse_timeout_seconds` begrenzt nur die Wartezeit. | ### Chunking-Konfiguration { #chunking-configuration } Pro Collection, neben dem Parser: | Einstellung | Vorgabe | Beschreibung | |---------|---------|-------------| | `chunk_size` | `2500` | Höchstzahl der Zeichen pro Chunk | | `chunk_overlap` | `200` | Zeichen der Überlappung; muss kleiner sein als `chunk_size` | | `chunking_strategy` | `recursive` | Strategie: `recursive`, `markdown`, `fixed` | **Vergleich der Strategien:** | Strategie | Am besten für | |----------|----------| | `recursive` | Allgemeiner Text; trennt an Absätzen, dann Zeilen, dann Wörtern, dann Zeichen | | `markdown` | Markdown und strukturierte Dokumente; trennt an Überschriftengrenzen, dann innerhalb jedes Abschnitts nach Größe | | `fixed` | Gleichmäßige Chunk-Größen; trennt nur an Zeilenenden, eine lange Zeile wird also ganz ausgegeben | Alle drei stammen aus `app/services/rag/_splitters.py`, das in [#158](https://github.com/vstorm-co/agenticos/issues/158) `langchain-text-splitters` ersetzt hat — zu den acht Paketen dahinter gehörte `langsmith`, ein zweites SDK für gehostete Telemetrie in einer Plattform, die sich auf Logfire festgelegt hat. Drei Dinge darüber sollte man wissen, bevor man an den Zahlen dreht: - **`chunk_overlap` ist eine Obergrenze, keine Zusicherung.** Ein Chunk wiederholt so viel vom vorherigen, wie noch unter `chunk_size` passt, und das ist häufig weniger als die Einstellung und manchmal gar nichts. - **Ein Stück ohne verbliebenen Trenner wird ganz ausgegeben, nicht zerschnitten.** `fixed` trennt nur an Zeilenenden, eine 4 KB lange Zeile wird also ein 4 KB großer Chunk; der Splitter schreibt eine Warnung, statt dem Embedding-Modell etwas zu geben, das es zurückweisen wird. Die Warnung bedeutet *über* `chunk_size` — eine Zeile von genau `chunk_size` Zeichen liegt innerhalb der Grenze und geht stillschweigend durch. - **`markdown` behält die Überschrift im Chunk**, und bis #158 wandte es weder `chunk_size` noch `chunk_overlap` an — ein 50 KB großer Abschnitt zwischen zwei `##` war ein Chunk. Es lässt nun den rekursiven Splitter über jeden Abschnitt laufen, beide Einstellungen bedeuten bei dieser Strategie also dasselbe wie bei den anderen. Chunk-Grenzen sind das, wogegen eine Suche trifft, eine vor dieser Änderung eingelesene Collection behält also die Chunks, mit denen sie eingelesen wurde. Laden Sie ein Dokument erneut hoch oder führen Sie `uv run agenticos cmd rag-ingest` erneut aus, um es neu zu chunken. **Wie viele Chunks ein Dokument hat, entscheidet, wie lange das Speichern dauert, aber nicht mehr, wie viele Roundtrips es kostet.** `insert_document` schreibt sie mit 200 Zeilen je Statement (`executemany`, das asyncpg pipelined). Früher setzte es in einer Python-Schleife innerhalb einer offenen Transaktion ein `INSERT` pro Chunk ab — ein 200-seitiges PDF bei der voreingestellten `chunk_size` waren also ein- bis dreitausend sequenzielle Roundtrips: fünf bis fünfzehn Sekunden gegen ein verwaltetes Postgres bei 3-5 ms, bevor ein einziges Embedding bezahlt war ([#950](https://github.com/vstorm-co/agenticos/issues/950)). Es wird in Stapeln geschrieben statt in einem Statement für das ganze Dokument, weil die Parameterliste im Speicher gehalten wird und jede Zeile ihr Embedding als Text gerendert trägt — bei 3072 Dimensionen zehntausende Bytes pro Zeile. **Eine Überschreibung wird gegen das zusammengeführte Paar geprüft, nicht gegen ihren eigenen Wert.** Ein `ingestion`-Feld pro Upload trägt nur, was es ändert, `chunk_overlap: 4096` an eine Collection gesendet, die bei 2500 chunkt, sind also zwei einzeln legale Zahlen und eine Konfiguration, die fast alles wiederholt, worüber sie hinausgeht. Die Zusammenführung validiert erneut, und der Upload wird mit einer **400** abgelehnt, die beide Einstellungen in `details.fields` nennt — bevor die Datei gespeichert ist und bevor eine Dokumentzeile existiert, es gibt also nichts zu wiederholen oder aufzuräumen. Bis [#874](https://github.com/vstorm-co/agenticos/issues/874) antwortete sie mit 500 und leerem `details`: die Zusammenführung warf einen rohen Pydantic-Fehler, der keinen Handler erreicht. Dasselbe Paar, als eigene Konfiguration einer Collection gesendet, wurde immer mit einer 422 abgelehnt, denn dort ist es ein Feld eines JSON-Körpers, und FastAPI validiert es, bevor die Route betreten wird. Beide Ablehnungen nennen dieselben Felder, `ingestion_config` für die Paarregel und `ingestion_config.chunk_size` für eine Einstellung für sich — das Formular markiert also eine Stelle, welcher Einstiegspunkt auch abgelehnt hat. (Die Paarregel nennt das Objekt, weil Pydantic einen `model_validator(mode="after")` keinem der beiden Felder zuordnet, um die es geht.) Die 400 nannte ihre Felder bis [#882](https://github.com/vstorm-co/agenticos/issues/882) unter `details.errors`, in Pydantics eigenem Fehlerformat, das im Frontend nichts las: der Satz erreichte einen Toast, und kein Eingabefeld wurde je hervorgehoben. ### Embeddings — das Modell, wessen Endpunkt antwortet und wessen Schlüssel zahlt { #embeddings-the-model-whose-endpoint-answers-and-whose-key-pays } Alle drei werden **pro Collection** entschieden, nicht pro Deployment, von `app/services/embedding_resolution.py` über den Katalog in `app/core/catalog/embedding_providers.json`: | | | |---|---| | **Modell und Breite** | Bei der Erstellung auf der Wissensdatenbank festgehalten (`embedding_model`, `embedding_dim`) und danach nie geändert — `PgVectorStore` schreibt `embedding vector(N)` ein einziges Mal, ein zweites Modell lässt sich also entweder nicht schreiben oder wird still gegen Vektoren aus einem anderen Raum verglichen. Eine neue Collection wählt eines der Models, die ihr Provider bereitstellt; einen Deployment-Standard gibt es nicht. | | **Provider** | Welcher OpenAI-kompatible Endpunkt dieses Modell bedient (`embedding_provider`). **Änderbar**, anders als das Modell: dasselbe Modell in derselben Breite erzeugt Vektoren im selben Raum, von wo aus es auch bedient wird, `PATCH /kb/{id}` verschiebt eine Collection also zwischen Providern und lässt alles bereits Indexierte gültig. | | **Zugangsdaten** | Der auf der Collection gewählte Vault-Schlüssel (`embedding_secret_id`), der der Organisation in Rechnung gestellt wird und der ein Schlüssel **für diesen Provider** sein muss. Einen deploymentweiten Embedding-Schlüssel gibt es nicht: Eine neue persönliche oder Organisations-Collection muss einen benennen, und eine Collection ohne brauchbaren Schlüssel verweigert Indexierung und Suche, bis sie einen hat. Der Provider `ollama` ist **schlüssellos** — ein Ollama im Netz des Deployments selbst — eine Collection auf ihm benennt also keinen Schlüssel und wird abgelehnt, wenn sie es versucht; sie benennt stattdessen einen **lokalen Dienst** (`embedding_endpoint_id`), eine Zeile unter Knowledge → Integrations, die die Adresse trägt, den eigenen der Organisation oder einen deploymentweiten, den der App-Administrator registriert hat. Eine **app-scoped** Collection gehört zu keiner Organisation und hat damit keinen Vault, aus dem sie einen Schlüssel benennen könnte; sie darf nur über einen schlüssellosen Provider an einem deploymentweiten Dienst einbetten, und die Wahl eines Providers mit Schlüssel wird für sie dort abgelehnt, wo der Provider gewählt wurde. | Auf welche Wissensdatenbank sich der Name einer Collection auflöst, ist selbst eine Tenant-Frage. `collection_name` ist indiziert, aber **nicht eindeutig** — zwei Organisationen können eine Collection gleich nennen und sich eine Vektortabelle teilen — deshalb ist die Auflösung auf die Organisation begrenzt, *für* die eingebettet wird: die des einlesenden Flows, die des suchenden Agents. Die von der Datenbank zuerst sortierte Zeile zu nehmen würde das Modell eines anderen Tenants auflösen und *dessen* Vault-Schlüssel für diese Anfrage entsiegeln, ihm die Rechnung stellen und den Text dieser Organisation durch seine Zugangsdaten laufen lassen ([#913](https://github.com/vstorm-co/agenticos/issues/913)). Ein geteilter Name löst daher die eigene Konfiguration jeder Organisation auf, mit Rückfall auf eine app-weite Collection, aber nie auf die eines dritten Tenants. Der Rückfall ist ausschließlich eine app-weite Basis: Eine persönliche Basis außerhalb jeder Organisation hat ebenfalls keine Organisation, gehört aber einer einzelnen Person ([#2537](https://github.com/vstorm-co/agenticos/issues/2537)). Eine Sync-Quelle hält eine Organisation und einen Collection-Namen fest, keine Wissensbasis, also schreibt ihr Sync in die Basis, die diese Auflösung wählt - die älteste Basis der Organisation mit diesem Namen -, und nur diese Basis listet und startet sie. Jede API-Route, die eine Quelle auf eine Collection richtet, prüft Schreibrechte auf diese Basis, und eine Quelle einer anderen Basis desselben Namens hinzuzufügen, wird mit 400 abgelehnt. Ist die persönliche Basis eines Mitglieds älter als die gleichnamige Basis der Organisation, landen die Syncs der Organisation unter diesem Namen deshalb in der persönlichen Basis, und die Basis der Organisation nimmt keine Quelle an: Lege sie unter einem eigenen Namen an. !!! warning "Ein Collection-Name ist ein Embedding-Raum" Pro Organisation aufzulösen ist nur deshalb sicher, weil sich alle Zeilen zu einem Collection-Namen darüber einig sind, wie eingebettet wird. Eine Wissensdatenbank, die gegen einen bereits existierenden Namen angelegt wird, **übernimmt** Modell, Breite, Provider und Vault-Schlüssel dieser Collection, und eine ausdrückliche Wahl, die dem widerspricht, wird abgelehnt statt stillschweigend überschrieben. Ohne das könnte eine physische Tabelle zwei Embedding-Räume halten: pgvector lehnt den Vergleich rundheraus ab, wo die Breiten sich unterscheiden, und wo sie zufällig übereinstimmen, bewertet es die Vektoren des einen Modells gegen die des anderen und antwortet mit plausiblem Unsinn. Der Provider war früher fest verdrahtet: jede Anfrage ging an `openrouter.ai`, eine Organisation mit einem OpenAI-Schlüssel konnte ihn also nicht nutzen, ein Schlüssel, der zu einem anderen Konto wanderte, bedeutete, die Collection neu anzulegen und jedes Dokument erneut einzulesen, und nichts hinderte eine Collection daran, die Zugangsdaten eines Anbieters an die Adresse eines anderen zu senden. Der Katalog ist auch das, womit `GET /rag/embedding-models` antwortet, das Anlageformular bietet also die Modelle an, die ein Provider tatsächlich bedienen kann — früher bot es jedes Modell an, für das dieser Build eine *Breite* kannte, darunter drei Sentence-Transformer-Gewichte, die hier nichts aufrufen kann. Eine OpenRouter-Collection kann einen eigenen **Endpoint** (`embedding_base_url`) nennen, etwa den regionalen `https://eu.openrouter.ai/api/v1`. Ein regionaler Endpoint stellt nur einen Teil des Katalogs bereit, und nur er weiß, welchen. Deshalb fragt `GET /rag/embedding-models/openrouter?base_url=…` ihn und antwortet mit den Katalogmodellen, die er aufführt. Die Breite liefert weiterhin der Katalog, weil die Liste keine nennt. Das Anlageformular bietet diese engere Liste an, und der Service lehnt eine Collection ab, deren Modell der Endpoint nicht bereitstellt: beim Erstellen auf `embedding_model`, beim Verschieben einer bestehenden auf `embedding_base_url`. Ein Endpoint, der nicht gefragt werden kann, gilt nicht als Ablehnung: Das Formular zeigt den ganzen Katalog und kennzeichnet ihn als unbestätigt. Ein Modell, das der Endpoint nicht bereitstellt, wurde früher angenommen, und dann scheiterte jeder Upload mit OpenRouters "No endpoints found supporting your data region", gespeichert als allgemeiner Hinweis, den Schlüssel zu prüfen. Ein 404 eines Embedding-Endpoints wird jetzt als das Modell gespeichert, das er nicht bereitstellt. Der Schlüssel wird bei der Erstellung validiert. Ein Schlüssel, den eine andere Organisation hält, einer mit dem falschen Zweck oder einer, den die wählende Person selbst nicht sehen kann, wird dort abgelehnt, wo sie es beheben kann. Der letzte Fall ist der Grund, warum das Binden `secrets:view` auf dem Schlüssel braucht und nicht nur `collections:edit` auf der Collection: **einen Schlüssel zu binden heißt, ihn zu verleihen**, denn die Embeddings der Collection rechnen für jeden ab, der in die Collection schreiben kann. Die Auswahl bot immer nur Schlüssel an, die die wählende Person sehen kann — aber die API nimmt eine Id, und eine Id lässt sich raten. Bis [#912](https://github.com/vstorm-co/agenticos/issues/912) konnte ein Member den **privaten** Schlüssel eines anderen Mitglieds binden, indem er dessen UUID angab. Ein Schlüssel, den sie nicht sehen können, wird als einer abgelehnt, den der Vault nicht hält, damit die Ablehnung nicht die privaten Secrets anderer aufzählen kann. Zur Embedding-Zeit lehnt der Resolver nichts ab: ein gewählter Schlüssel, der seither gelöscht wurde, nicht entsiegelt werden kann oder keinen API-Schlüssel hält, löst sich auf *keinen* Schlüssel auf, denn *wessen Schlüssel zahlt* darf nie entscheiden, *ob die Zeile der Collection gelesen werden kann*. Der Embedding-Client verweigert dann die Indexierung oder die Suche mit einer Meldung, die die Collection, ihren Provider und den eingetretenen Fall nennt — es gibt keinen deploymentweiten Schlüssel, auf den zurückzufallen wäre, die Ablehnung rät also nie zu einer Variablen. Diese Verschlechterung wird angekündigt statt angenommen. Die Auflösung trägt mit, auf welcher der fünf Quellen sie gelandet ist, und die Ingestion schreibt jeden verschlechterten Fall in das Log des Workers, eine Collection, die schlicht keinen Schlüssel benennt, eingeschlossen. Vor Issue #306 war der Ingestion-Worker der eine Aufrufer, der den Resolver überhaupt nie fragte, jedes hochgeladene Dokument wurde also mit dem Model des Deployments und einem deploymentweiten Schlüssel eingebettet, was seine Collection auch gewählt hatte; diesen Schlüssel gibt es nicht mehr. ### Vektorspeicher { #vector-storage } Vektoren werden in **pgvector** gespeichert, in der vorhandenen PostgreSQL-Datenbank. Es sind keine zusätzlichen Dienste nötig. **Eine Tabelle pro Collection, zur Laufzeit angelegt.** Der Speicher setzt `CREATE TABLE IF NOT EXISTS rag_` ab, wenn zum ersten Mal in eine Collection geschrieben wird, diese Tabellen existieren also in der Datenbank und sonst nirgends. Kein Modell deklariert sie und keine Migration legt sie an, denn ein Deployment hält so viele, wie jemand Wissensdatenbanken angelegt hat. Alembic besitzt sie nicht, und `alembic/env.py` sagt das über `include_name`. Ohne das las `make db-check` jede davon als Tabelle, die die Modelle entfernt hätten, und scheiterte auf jeder Datenbank, in die je ein Dokument eingelesen worden war. Das Kriterium steht in `app/db/vector_tables.py`, und es ist absichtlich enger als das Präfix: `rag_documents` *ist* eine Modelltabelle, und sie auszuschließen hätte das Tor genau für die eine Tabelle abgeschaltet, durch die die Ingestion schreibt. Der Speicher beantwortet dieselbe Frage mit demselben Kriterium: `list_collections`, was `rag-collections` ausgibt, meldet eine `rag_`-Tabelle nur dann, wenn kein Modell sie deklariert. Allein auf das Präfix zu treffen ließ es `rag_documents` als Collection namens `documents` melden — eine, die niemand angelegt hatte, deren "Vektoranzahl" die Zahl der eingelesenen Dokumente war, und die jeder Aufrufer dann durchsuchen lassen konnte. #### Wie eine Collection heißen darf { #what-a-collection-may-be-called } Ein Collection-Name ist eine Zeichenkette, die ein Aufrufer wählt und aus der der Speicher Bezeichner baut, deshalb **entscheidet eine Funktion, ob er brauchbar ist** — `validate_collection_name` in `app/db/vector_tables.py`. Vier Ablehnungen, jede eine 400: | Abgelehnt | Weil | |---|---| | Kein nackter Bezeichner — `foo-bar`, `2024_reports`, alles mit einem Leerzeichen oder einem Anführungszeichen | Der Speicher setzt den Namen unquotiert in DDL ein. Eine führende Ziffer *sieht* nur sicher aus: das Präfix `rag_` liefert den Buchstaben, der dem Namen fehlt. | | Jede Großschreibung — `Handbook` | Postgres klappt einen unquotierten Bezeichner um, `Handbook` und `handbook` sind also eine Tabelle. Nichts oberhalb der Datenbank kann das sehen: Namen werden überall sonst als ganze Zeichenketten verglichen, die beiden sind also zwei Zeilen, die die Plattform für zwei Collections hält. Abgelehnt statt kleingeschrieben — einen Namen zu speichern, den der Aufrufer nicht getippt hat, ist genau die Umdeutung, die diese Regel verhindern soll. | | Länger als 45 Zeichen | Postgres behält 63 Bytes eines Bezeichners und schneidet den Rest stillschweigend ab. `rag_` passt bei 59, `rag__embedding_idx` aber nicht, und die Grenze richtet sich nach dem längsten Bezeichner — nicht nach dem kürzesten. | | `all` | Reserviert. | | Eine Tabelle, die den Modellen gehört — `documents` | Siehe unten. | Zwei davon sind derselbe Fehlschlag, nur anders erreicht, und beide sind einen Satz wert. Die **Längengrenze** ist die, die sich wie Pedanterie liest und keine ist. Zwei Collections, die bis zum Abschneidepunkt übereinstimmen, sind **ein Objekt**: - **Eine Tabelle**, wenn der Name zu lang war — dann zerstört das `DROP` jeder der beiden Organisationen die Vektoren der anderen, und jede Suche geht quer über beide. - **Ein Index**, wenn nur der Indexname zu lang war, was leiser ist: `CREATE INDEX IF NOT EXISTS` findet den Index der ersten Collection schon vor und baut nichts, und lässt die zweite unindiziert, in der Breite, in der die erste gebaut wurde. Nichts oberhalb der Datenbank kann eines von beiden sehen, denn ein Collection-Name wird überall sonst als ganze Zeichenkette verglichen. Und genau deshalb zählt auch die **Groß- und Kleinschreibung**: eine Schreibweise ist ein kürzerer Weg zur selben geteilten Tabelle, und Großschreibung abzulehnen schließt einen zweiten mit. `_collection_exists` verglich `rag_Handbook` mit `information_schema.tables`, das den umgeklappten Namen speichert, es traf also nie zu, und `search`, `get_documents` und `get_document_chunks` antworteten **leer** für jede Collection mit einem Großbuchstaben darin. Dieser Weg ist beseitigt statt repariert: ein solcher Name wird jetzt dort abgelehnt, wo der Tabellenname gebaut wird, bevor irgendetwas fragen kann. **Eine Collection darf nicht nach einer Tabelle heißen, die den Modellen gehört**, und das ist das Laufzeittabellen-Kriterium ein drittes Mal gelesen — an einen Namen gestellt, bevor seine Tabelle existiert. Abgelehnt sowohl an der API als auch im Speicher selbst, denn `rag-drop ` erreicht den Speicher ohne Route dazwischen. Der Name, der das nötig machte, ist `documents`: mit Präfix *ist* er die Verfolgungstabelle, eine solche Collection zu löschen richtete also `DROP TABLE IF EXISTS` auf die Ingestion-Historie jeder Organisation. Die Ablehnung ist abgeleitet statt aufgelistet, eine später hinzugefügte Modelltabelle mit `rag_`-Präfix ist also abgedeckt, und eine Collection namens `documents_archive` — die ein wörtlicher Ausschluss mitgenommen hätte — ist nicht betroffen. **Und der Name muss frei sein.** Der Vektor-Namensraum gilt für das ganze Deployment: zwei Wissensdatenbanken, die einen Collection-Namen halten, teilen sich eine Tabelle. Ein Name, der bereits außerhalb der Reichweite des Aufrufers gehalten wird, wird deshalb mit einer 409 abgelehnt — `CollectionAccessService.claim`, das sowohl `POST /kb` als auch `POST /rag/collections/{name}` aufrufen. Früher tat das nur eines von beiden. `POST /kb` schrieb, welchen `collection_name` es auch gesendet bekam, ein Mitglied mit `collections:edit` konnte eine Wissensdatenbank also auf die Vektortabelle einer anderen Organisation richten und sie danach durch jedes Tor lesen und beschreiben — denn eine Collection wird über diejenige Wissensdatenbank aufgelöst, die der Aufrufer lesen *kann*, und jetzt ist eine davon seine. Ein Name, den ein Aufrufer nicht angibt, wird aus dem Anzeigenamen plus sechs zufälligen Hexzeichen abgeleitet und auf demselben Weg beansprucht, statt ihm wegen seiner Zufälligkeit zu vertrauen. **Auch ein Name mit Inhalt, den keine Basis besitzt, ist nicht frei.** `rag-ingest` schreibt Tracking-Zeilen und eine `rag_`-Tabelle ohne Wissensbasis und ohne Organisation. Deshalb lehnt `claim` einen Namen, den keine Basis hält, ab, solange er keiner Basis zugeordnete Zeilen mit Chunks eines anderen Tenants als der Organisation des Aufrufers hält, oder eine Vektortabelle mit solchen Chunks. Früher konnte ein Mitglied mit `collections:edit` eine Basis auf der Collection eines Operators anlegen und löschen und damit dessen Chunks verwerfen ([#2747](https://github.com/vstorm-co/agenticos/issues/2747)). Ein Name, den eine Basis hält, wird nicht geprüft: Der Aufrufer darf jede dieser Basen schreiben und könnte sie ohnehin alle löschen oder leeren. Ein App-Admin darf einen solchen Namen übernehmen; eine fehlgeschlagene Zeile ohne Vektor-Id beschreibt keinen Chunk und hält den Namen nicht. Ein mitten in einer Datei abgebrochenes `rag-ingest` hinterlässt eine `processing`-Zeile, die den Namen hält, bis ein App-Admin ihn übernimmt oder `rag-drop ` ausführt. Inhalt, der *nach* dem Beanspruchen durch eine Basis unter den Namen kommt, lässt sich hier nicht ablehnen, deshalb schont ihn der Abbau: Die Tabelle der letzten Basis bleibt, solange sie einen nicht zugeordneten Chunk hält, den die Basis nicht übernimmt, wie unten beim Löschen einer Basis beschrieben. **Und ein Name mitten im Abbau ist auch nicht frei.** Eine Collection zu löschen entfernt ihre Wissensdatenbank-Zeilen in der Anfrage, lässt die physische Vektortabelle `rag_` aber erst *nach* dem Commit der Anfrage fallen, einem dauerhaften Worker übergeben — ein Rollback behält die Tabelle also neben den Zeilen, die er wiederherstellt, und ein Prozess, der mitten im Aufräumen stirbt, lässt sie nicht verwaisen. Zwischen diesem Commit und dem Löschen hat der Name keine Zeile, seine Tabelle hält aber noch die Chunks des alten Tenants, deshalb lehnt `claim` auch einen in `collection_teardowns` reservierten Namen ab — eine Zeile, die *mit* dem Löschen committet und geleert wird, sobald die Tabelle weg ist. Ohne sie würde ein Anspruch in diesem Fenster per `CREATE TABLE IF NOT EXISTS` die verbliebene Tabelle übernehmen und die Daten eines anderen Tenants lesen (#1362). Ein **Upload** auf einen reservierten Namen wird aus denselben Gründen abgelehnt: `RAGDocumentService.dispatch_upload` prüft die Reservierung, bevor es die Collection anlegt, eine in das Fenster gerutschte Ingestion kann die Tabelle, die das Löschen gleich zerstört, also nicht neu anlegen und ihre eigenen Chunks daran verlieren (#1364). Die **Worker**-Ingestion-Wege — ein Sync, ein erneuter Versuch — prüfen sie ebenfalls, am `still_wanted`-Tor unmittelbar vor dem Vektorschreiben, ein Sync in eine geleerte Vorgabe (deren Zeile das Leeren behält) hält also an, statt eine Tabelle neu zu füllen, die gerade gelöscht wird (#1382). Beides sind Best-Effort-Prüfungen der Reservierung und keine mit Sperren serialisierte Abfolge. Ein Schreibvorgang, der unter einer Basis abgelegt ist, stützt sich auf sie und auf die Sperren seiner Basis und Zeile, und das Schließen des letzten schmalen Fensters bleibt #1382 überlassen. Ein Chunk-Schreibvorgang ohne Basis hält die Teardown-Sperre bis zu seinem Commit, wie unten beim Teardown der letzten Basis beschrieben. Eine Reservierung, deren Löschen nie lief — verloren durch einen Absturz zwischen Commit und Anstoß oder durch ein Löschen, das endgültig scheitert — würde ihren Namen für immer blockieren, denn nichts sonst versucht es erneut. Ein **stündlicher Durchlauf** (`teardown-reservation-sweep`) erntet diese ab: für jede Reservierung, die älter als eine Stunde ist, wiederholt er das Löschen und gibt den Namen frei, ein Name geht also nicht endgültig verloren, weil ein Worker-Durchlauf es tat (#1364). **Eine Vorgabe-Wissensdatenbank wird geleert, nicht gelöscht.** Eine Vorgabe-Collection zu löschen behält ihre Wissensdatenbank-Zeile — die Organisation behält eine brauchbare Vorgabe — aber ihre Vektortabelle wird trotzdem gelöscht, die damit entfernten Dokumente hören also auf, durchsuchbar zu sein, statt in einer Tabelle zu verharren, die nichts auflistet (#1361). Eine Suche liest die fehlende Tabelle als leer, und der nächste Upload legt sie neu an. Die Tabelle wird nur dann verschont, wenn eine Geschwister-Wissensdatenbank denselben Namen hält, denn der Vektor-Namensraum ist nicht Tenant-eindeutig und sie zu löschen würde deren Chunks mitnehmen (#913). Dann verschwinden nur die Dokumentzeilen und Dateien der gelöschten Basis, und ihre Chunks werden aus der behaltenen Tabelle entfernt, wie unten für eine gelöschte Basis. Nicht zugeordnete Dokumentzeilen der Organisation der Basis (Uploads aus der Zeit, bevor Dokumente ihre Basis speicherten) verschwinden mit ihren Dateien und Chunks ebenfalls, außer eine andere Basis dieser Organisation hält den Namen und listet sie noch. Das Löschen einer Collection löschte früher jede Dokumentzeile unter dem Namen, auch die einer anderen Organisation (#2560). `rag-drop ` läuft durch denselben Teardown. Früher löschte es die Vektortabelle direkt, ohne Referenzprüfung, Sperre oder Reservierung, und ließ die Dokumentzeilen zurück, sodass es eine Tabelle zerstören konnte, die eine andere Basis noch nutzte. Jetzt leert es die eine Basis, die den Namen hält, oder löscht einen Namen ohne Basis (von `rag-ingest` befüllt) mit seinen Dokumentzeilen. Halten mehrere Basen den Namen, lehnt es ab und listet ihre IDs; `--knowledge-base ` nennt die zu leerende Basis, und die Tabelle bleibt für die anderen. Ein Worker löscht Tabelle und Dateien, nachdem der Befehl committet hat (#2560). Die Tabelle der letzten Basis geht, gleich was sie hält: Der Operator hat das Löschen verlangt, und ihm gehört der Inhalt, den ein Löschen in der Konsole schonen würde (#2747). `documents` war auch die **Vorgabe**-Collection, der CLI-Schnellstart zielte also früher auf die Verfolgungstabelle; die Vorgabe ist jetzt `default`. Eine Wissensdatenbank, die vor dieser Änderung mit dem alten Namen angelegt wurde, existiert weiterhin und lässt sich weiterhin löschen, aber es kann nichts in sie eingelesen werden — löschen Sie sie und legen Sie eine unter einem anderen Namen an. Dabei geht nichts verloren: eine Ingestion in diese Collection war nie erfolgreich, denn den Vektorindex auf einer Tabelle ohne `embedding`-Spalte zu bauen scheitert. ### Wer eine Collection erreichen darf { #who-may-reach-a-collection } Collections sind nicht global, und niemand innerhalb einer Organisation ist für diesen Zweck ein "Admin" — es gibt hier keine Rollen auf einer Route, nur Berechtigungen ([Berechtigungen](permissions.md)). Eine Collection hat zwei Namen. Der eine ist die Vektortabelle, in der die Chunks liegen, und das ist eine Zeichenkette, die jeder Aufrufer in eine URL tippen kann; der andere ist die `knowledge_bases`-Zeile, die sie besitzt, und nur diese Zeile kennt eine Organisation. **Die Zeile ist die Autorität**: jede `/rag`- und `/kb`-Route löst den Namen über sie auf, in `app/services/collection_access.py`, bevor sie einen Vektor, ein Dokument oder eine Sync-Source anfasst. Die Auflistung und die Routen pro Ressource lesen die Regel an dieser einen Stelle, denn es waren zwei Kopien davon — `/rag/collections` filterte nach Organisation, während `/rag/collections/{name}/info` es nicht tat — die einst einen Tenant die Daten eines anderen lesen ließen. Drei Geltungsbereiche auf der Zeile, und keinen vierten: | Geltungsbereich | Darf lesen | Darf schreiben | |---|---|---| | `personal` | ihr Eigentümer | ihr Eigentümer | | `org` | `collections:view`, das die Zeile erreicht | `collections:edit`, das die Zeile erreicht | | `app` | jeder im Deployment | der Deployment-Superadmin (`is_app_admin`) | "Die Zeile erreichen" ist `resolve_access`, dieselbe Entscheidung, die jede teilbare Ressource trifft: der Geltungsbereich des Aufrufers für diese Berechtigung, geweitet durch jeden ausdrücklichen Grant auf genau diese Collection. Ein Grant weitet, was eine Rolle erlaubt, und verengt es nie, deshalb **kann ein Viewer mit einem ausdrücklichen `edit`-Grant diese Collection verwalten** — der Fall, den ein Rollentor ablehnen würde, bevor es überhaupt hinsieht. Deshalb tragen die Routen pro Ressource kein `require(...)` und übergeben die Entscheidung stattdessen an den Service. Was das je Operation bringt: | | | |---|---| | Suche — `POST /rag/search` und das Retrieval-Tool des Agents | `collections:view`. Jede genannte Collection wird aufgelöst, bevor der erste Vektor gelesen wird, und eine, die der Aufrufer nicht erreichen kann, lässt die **ganze** Suche abgelehnt werden, statt still aus ihr entfernt zu werden | | Lesen — Collections und Dokumente auflisten, Collection-Kennzahlen, der geparste Text oder die Originaldatei eines Dokuments, Sync- und Ingestion-Logs | `collections:view`, und jede Antwort hält nur die Collections, die dieser Aufrufer erreichen kann | | Schreiben — eine Collection anlegen und löschen, hochladen, einlesen, erneut versuchen, ein Dokument löschen, eine Sync-Source konfigurieren oder abbrechen | `collections:edit` | | `POST /rag/sync/local` | Die eine Ausnahme, und sie behält `is_app_admin`: ihr `path` nennt ein Verzeichnis auf dem **Server** statt etwas, das einem Tenant gehört, sie für `collections:edit` zu öffnen würde also jedem Mitglied das Lesen beliebiger Serverdateien geben, eingelesen in eine Collection, die es danach durchsuchen kann | Eine Ablehnung wird als **"Collection not found"** gemeldet, mit derselben Meldung und denselben Details, die eine nicht vorhandene Collection erzeugt. Alles andere macht die API zu einem Orakel: diese Namen leiten sich davon ab, wie Menschen ihre Wissensdatenbanken nennen, zu bestätigen, dass `acme_handbook_d1fac1` irgendwo existiert, ist also bereits eine Information. **Innerhalb einer Collection gibt es keine Isolierung pro Dokument.** Der Zugriff wird an der Collection entschieden, eine zu erreichen erreicht also jedes Dokument darin — und genau das ist abzuwägen, wenn man entscheidet, was wohin eingelesen wird. ### Eine Suche eingrenzen { #narrowing-a-search } `POST /rag/search` und das Retrieval-Werkzeug des Agents nehmen neben der Anfrage ein `filters`-Objekt entgegen. Vier Dimensionen, alle beim Ingest auf jeden Chunk gestempelt: | Feld | Ist | |---|---| | `source` | Woher das Dokument kam — der Upload, ein Sync-Konnektor — aus einem festen Vokabular | | `document_type` | Der geparste Filetype, beim Ingest abgeleitet statt mitgegeben | | `organizational_unit` | Die eine vom Autor gesetzte, korpusabhängige Dimension | | `date_from` / `date_to` | Ein einschließender Bereich über das eigene Datum des Dokuments | **ODER innerhalb eines Feldes, UND über die Felder hinweg.** `source: ["upload", "google_drive"]` trifft beides; `document_type: ["pdf"]` daneben grenzt auf PDFs unter diesen beiden ein. Ein Chunk ohne Wert für eine gefilterte Dimension **schließt sich selbst aus** — er wird ausgeschlossen statt zugelassen, so dass ein Filter nie erweitert, was die Anfrage ohnehin schon erreichte. Eine leere Liste wird mit 422 abgelehnt statt als „trifft alles" gelesen — dieselbe Regel „Ablehnung vor Stille", der diese Seite durchgehend folgt. **Ein Filter reicht nicht in einen anderen Tenant.** Die Tenant-Einschränkung wird aus der eigenen aufgelösten Collection des Aufrufers gebaut, nie aus dem Request-Body — es gibt kein Feld, in dem sich einer benennen ließe — und sie wird in die Anfrage ge-UNDet, bevor die Top-k berechnet werden, so dass ein selektiver Filter keine Zeile von außerhalb des Scopes hervorholen kann. Eine app-scoped, deploymentweite Collection wird darüber getroffen, dass *kein Tenant aufgestempelt ist*, statt über eine Gleichheit, an der jede Organisation scheitern würde - und genau das lässt alle die geteilte Basis lesen, ohne dass jemand die der anderen liest. **Ein Filter reicht auch nicht in eine andere Wissensbasis desselben Namens.** Der Tenant allein unterscheidet nicht jedes Paar: Eine app-weite Basis und eine persönliche Basis außerhalb jeder Organisation haben keinen, und die Basis einer Organisation und die persönliche Basis eines Mitglieds darin haben dieselbe Organisation. Deshalb hält jeder Chunk auch die Wissensbasis fest, für die er indexiert wurde, und Suche, Vektorzählung, die Suche beim Ersetzen und das Löschen lesen nur die Chunks der eigenen Basis; das Löschen eines Dokuments entfernt auch seine eigenen Chunks, die keine Basis festhalten. Ein Chunk, der geschrieben wurde, bevor Chunks ihre Basis festhielten, und den das Upgrade nicht zugeordnet hat, passt wie bisher über seinen Tenant - außer für eine persönliche Basis außerhalb jeder Organisation, deren Suche und Zählung keinen davon nehmen ([#2537](https://github.com/vstorm-co/agenticos/issues/2537)). Das Löschen einer Wissensbasis, deren Collection-Namen eine andere Basis noch hält, behält die gemeinsame Tabelle, und der mit dem Löschen eingereihte Aufräum-Job entfernt die Chunks, die der gelöschten Basis zugeordnet sind. Das Löschen einer Organisation und das Löschen eines Kontos tun dasselbe für die Basen, die sie entfernen, und ebenso das Löschen einer Collection (`DELETE /rag/collections/{name}`), das auch nur die Dokumentzeilen dieser Basis löscht; bei einer behaltenen Standardbasis entfernt es nur die Chunks der gelöschten Dokumente, sodass ein Upload nach dem Löschen seine eigenen behält und ein Chunk ohne Dokumentzeile bleibt ([#2560](https://github.com/vstorm-co/agenticos/issues/2560)). Wird eine Wissensbasis gelöscht (`DELETE /kb/{id}`), während eine andere Basis ihren Namen hält, gehen auch die nicht zugeordneten Dokumentzeilen ihrer Organisation mit ihren Dateien und Chunks, außer eine andere Basis dieser Organisation hält den Namen und listet sie noch - dieselbe Regel wie beim Löschen einer Collection ([#2560](https://github.com/vstorm-co/agenticos/issues/2560)). Sonst bleiben nicht zugeordnete Chunks und passen über ihren Tenant, und nicht zugeordnete Dokumentzeilen bleiben in den Basen ihres Tenants gelistet ([#2537](https://github.com/vstorm-co/agenticos/issues/2537)). Hält keine andere Basis den Namen, löschen beide Wege und `rag-drop` dieselben Zeilen mit ihren Dateien, und der Aufräum-Job löscht die Tabelle: die eigenen Zeilen der Basis und die nicht zugeordneten Zeilen ihres Tenants. Das sind für eine Basis einer Organisation oder eine persönliche Basis die Zeilen ihrer Organisation, für eine app-scoped Basis die Zeilen ohne Organisation (die `rag-ingest` schreibt) und für eine persönliche Basis außerhalb einer Organisation keine. Beide Routen behalten die Tabelle danach, wenn sie einen nicht zugeordneten Chunk hält, den die Basis nicht übernimmt: ein `rag-ingest` oder eine CLI-Synchronisierung des Operators in den gehaltenen Namen oder einen Chunk eines anderen Tenants. Ein Claim kann Inhalt, der erst danach kommt, nicht ablehnen, und der Aufrufer hat keine Befugnis darüber. Nur die Chunks, die die Basis übernimmt, werden entfernt, und die übrigen Zeilen unter dem Namen bleiben unverändert. Außer bei einer Standardbasis, die auf dem Namen bleibt, ist der Name so, wie `rag-ingest` ihn hinterlässt: Keine Basis hält ihn, `rag-drop ` entfernt ihn, und `claim` lehnt ihn für alle ab außer einem App-Admin und einer Organisation, deren Chunks als einzige übrig sind ([#2747](https://github.com/vstorm-co/agenticos/issues/2747)). Ein Chunk-Schreibvorgang ohne Basis hält den Namen bis zu seinem Commit. Die Tabelle wird deshalb erst geprüft, wenn jeder bereits laufende Schreibvorgang dieser Art beendet ist. Ein Schreibvorgang, der den Namen erreicht, solange er reserviert ist, wird abgelehnt, und die Datei muss erneut indexiert werden. Der Bereinigungsjob löscht nur einen reservierten Namen, ein erneuter Lauf lässt also einen Schreibvorgang nach seinem ersten Löschen bestehen. Ein großes Dokument kann den Namen mehrere Sekunden lang halten. Ein Anlegen auf diesem Namen (`POST /kb`, `POST /rag/collections/{name}`) wartet eine Sekunde und antwortet dann mit 409 und der Bitte, es erneut zu versuchen, denn jede wartende Anfrage belegt eine Datenbankverbindung, und genug davon lassen alle anderen Anfragen scheitern. Ein Löschen wartet, bis der Schreibvorgang beendet ist. Eine persönliche Basis außerhalb einer Organisation übernimmt keinen nicht zugeordneten Chunk, doch ein von ihren eigenen Zeilen benannter Chunk wird mit ihr entfernt. Ein alter Chunk ohne Basis, Organisation und Tracking-Zeile hat eine unbekannte Herkunft. Er erhält die Tabelle, ist aber **über eine app-scoped Basis nicht lesbar**, auch wenn ein App-Administrator den Namen übernimmt ([#2773](https://github.com/vstorm-co/agenticos/issues/2773)). Eine app-scoped Basis übernimmt globale nicht zugeordnete Chunks und Tracking-Zeilen nur mit `writer_kind: operator`. Neue Schreibvorgänge von `rag-ingest` und CLI-Syncs speichern diese Herkunft; Plattform-Ingestionen speichern `document`, auch über das Löschen eines Benutzers oder einer Basis hinaus. Migration `0300_unfiled_writer_provenance.py` vertraut alten Herkunftsmarkierungen nicht, erhält eindeutige Dokumentzuordnungen und isoliert mehrdeutige Daten als `legacy_unknown`. Nicht zugeordnete Organisationsdaten bleiben auf ihren Tenant begrenzt. **Nach dem Upgrade:** Prüfe eine erhaltene Collection in der Server-Shell. Der Befehl benötigt nur die Datenbank und zeigt aggregierte Zahlen, niemals Dokumentinhalte: ```bash uv run agenticos cmd rag-unfiled-provenance ``` Erst nach Prüfung der Quelle und Bestätigung, dass die unbekannten globalen nicht zugeordneten Daten ein Operator-Import für alle Organisationen sind, ausführen: ```bash uv run agenticos cmd rag-unfiled-provenance --confirm-operator ``` Eine interaktive Bestätigung ist erforderlich (`--yes` ersetzt sie bei Automatisierung). Die Aktualisierung ist atomar und wiederholbar; Chunks und Zeilen mit bekannter Dokumentherkunft werden übersprungen. Unsichere Daten bleiben isoliert; importiere sie erneut in ihre autorisierte Basis oder entferne sie mit dem bestehenden Operator-Verfahren `rag-drop `. Die Namensübernahme in der Konsole bestätigt niemals die Herkunft der Daten. Andernfalls, und bei `rag-drop` immer, löscht der Aufräum-Job die Tabelle. Nicht zugeordnete Zeilen eines anderen Tenants unter dem Namen bleiben mit ihren Dateien, denn der Aufrufer hat keine Befugnis über sie. Ohne die Tabelle steht nichts mehr hinter ihnen, deshalb werden sie als fehlgeschlagen markiert: Die nächste Basis dieses Tenants mit dem Namen listet sie als fehlgeschlagen, ein hochgeladenes Dokument muss erneut hochgeladen werden, und eine Synchronisierung indexiert ein synchronisiertes Dokument neu. Vorher ließ `DELETE /kb/{id}` die nicht zugeordneten Zeilen des Tenants hinter der gelöschten Tabelle zurück, weiter als indexiert gelistet, und das Löschen einer Collection löschte jede Zeile unter dem Namen, gleich welchem Tenant sie gehörte ([#2568](https://github.com/vstorm-co/agenticos/issues/2568)). Ein Upload oder eine Synchronisierung, die beim Löschen der Basis noch eine Datei für sie indexiert, hinterlässt keine Chunks. Der Schreibvorgang nimmt eine Sperre auf der Zeile der Basis, die mit der des Löschens kollidiert, in der Transaktion, die die Chunks einfügt: Ein Schreibvorgang, der als zweiter kommt, findet die Basis nicht mehr, schreibt nichts und schlägt im Indexierungsschritt fehl, und ein Schreibvorgang, der als erster kommt, wird vor dem Löschen festgeschrieben, dessen Aufräumen seine Chunks danach entfernt ([#2561](https://github.com/vstorm-co/agenticos/issues/2561)). Auch ein einzelnes Dokument, das während der Indexierung gelöscht wird, hinterlässt keine Chunks. Seine Zeile hat keine Vektordokument-ID, bis der Ingest eine einträgt, sodass das Löschen womöglich nichts zu entfernen hat; der Worker, der beim Festhalten des Ergebnisses die Zeile nicht mehr findet, reiht in derselben Transaktion einen Job ein, der die gerade geschriebenen Chunks entfernt und bei einem Fehlschlag wiederholt wird. Ein Löschen, das die neu eingetragene ID entdeckt, reiht denselben dauerhaften Job anhand der ID der gelöschten Zeile ein ([#2565](https://github.com/vstorm-co/agenticos/issues/2565)). Eine lokale Synchronisierung, die für eine vor ihrem Start gelöschte Basis eingereiht ist, endet mit einem Fehler und schreibt nichts ([#2561](https://github.com/vstorm-co/agenticos/issues/2561)). Ein Agent durchsucht die Wissensbasen, an die er gebunden ist, in ihrem eigenen Scope, statt ihre Namen erneut aufzulösen. Ein Agent, der an zwei Basen desselben Namens gebunden ist, durchsucht ihren gemeinsamen Scope und schließt andere gleichnamige Basen aus. **`GET /rag/collections/{name}/filter-values`** antwortet mit den `organizational_unit`-Werten, die in der Collection tatsächlich vorkommen, unter demselben Scope. Es existiert, weil diese Dimension das ist, was die Autoren geschrieben haben: ein geratener Wert liefert still nichts, und das liest sich als „dazu gibt es nichts" statt als „diese Einheit gibt es nicht". ### Woher die Organisationseinheit kommt { #where-the-organizational-unit-comes-from } Zwei Schreiber, und sonst setzt sie nichts. Eine **Sync-Quelle** trägt einen Standardwert, den jedes Dokument erbt, das sie einbringt — ein geteilter Ordner gehört einer Abteilung, und niemand beschriftet tausend synchronisierte Dateien einzeln. Ein **Upload** benennt eine für diese Datei, im Multipart-Body neben `ingestion`, und sie wird auf der Zeile des Dokuments selbst festgehalten statt an den Worker übergeben — so bindet ein Run, der vor diesem Feld eingereiht wurde, weiterhin. Die Konsole bietet beides: ein Feld im letzten Schritt des Sync-Assistenten und eines in *How the next uploads are read and filed*, das für jede weitere Datei gilt, bis es geleert wird. Freitext, und das mit Absicht. Das Vokabular ist das, was der Korpus eines Deployments tatsächlich verwendet, und `filter-values` meldet, was eine Collection wirklich hält — eine geschlossene Liste müsste gepflegt sein, bevor das erste Dokument irgendwo abgelegt werden könnte. Leer ist kein Wert: es wird als *keine Einheit* erfasst, damit die Facette nie eine Einheit namens `""` anbietet. **Es wird nichts nachgetragen.** Ein Dokument, das ingestiert wurde, bevor seine Quelle oder die hochladende Person eine Einheit benannt hat, trägt keine, und keine Regel kann entscheiden, zu welcher es gehört hätte. Ein solches Dokument wird von jedem Filter auf dieser Dimension ausgeschlossen — Chunks fallen zu „nein" — bis es neu ingestiert wird. Bis [#1777](https://github.com/vstorm-co/agenticos/issues/1777) schrieb das Feld überhaupt nichts, also traf der Filter nichts und `filter-values` antwortete in jedem Deployment für jede Collection mit einer leeren Liste. !!! warning "Der alte `filter`-String ist veraltet" `POST /rag/search` nahm früher einen `filter`-String, von dem nur `parent_doc_id == ""` je beachtet wurde — jede andere Klausel wurde kommentarlos verworfen. Jetzt nimmt er diesen einen Ausdruck und **lehnt alles andere mit 400 ab**, statt es zu ignorieren. Nutzen Sie `filters`; beides zugleich anzugeben, den String und `filters.parent_doc_id`, ist ein Konflikt, ebenfalls 400. ### Dokumentverfolgung { #document-tracking } Eingelesene Dokumente werden in der SQL-Datenbank über das Modell `RAGDocument` verfolgt: | Feld | Beschreibung | |-------|-------------| | `collection_name` | Ziel-Collection | | `filename` | Ursprünglicher Dateiname | | `filesize` | Dateigröße in Bytes | | `filetype` | Dateiendung (ohne Punkt) | | `status` | `processing`, `done` oder `error` — die Mitglieder von `DocumentStatus`, und die einzigen drei Werte, die die Spalte hält. Die Zahl der *indexierten* Dokumente einer Collection filtert auf `done`; bis [#148](https://github.com/vstorm-co/agenticos/issues/148) filterte sie auf einen vierten Wert, den nie etwas geschrieben hat, jede Wissensdatenbank meldete also `indexed_count: 0`, wie viele Dokumente auch fertig geworden waren | | `error_message` | Was gescheitert ist, wenn `status` gleich `error` ist — siehe unten | | `vector_document_id` | Id im Vektorspeicher | | `chunk_count` | Zahl der erzeugten Chunks. Seit [#147](https://github.com/vstorm-co/agenticos/issues/147) festgehalten; ein davor eingelesenes Dokument hält `0`, und die Karte seiner Collection meldet zu wenig, bis es neu eingelesen wird | | `storage_path` | Pfad zur Originaldatei (für erneute Ingestion/Download) | | `created_at` | Startzeit der Ingestion | | `completed_at` | Abschlusszeit der Ingestion | **Ein Ersatz setzt die Zeile außer Dienst, die er ersetzt.** Jeder Ingestion-Weg — der Upload, die CLI, ein Sync-Durchlauf — schreibt eine *neue* Verfolgungszeile, während eine Ingestion mit `replace=true` das Vektordokument löscht, das sie ablöst, und eines einfügt. Die ältere Zeile bleibt also zurück und beschreibt Vektoren, die niemand hält: ihr `chunk_count` wird weiterhin in die Summen der Collection eingerechnet, und ihre Ansicht des geparsten Inhalts hat nichts zu lesen. Eine abgeschlossene Ingestion löscht deshalb die Verfolgungszeilen, die auf das ersetzte Vektordokument zeigen, samt ihren gespeicherten Kopien der Datei. Ohne das meldete ein nächtlich synchronisiertes Verzeichnis eine Collection, die jede Nacht um ihre eigene Größe wuchs. **Ein synchronisiertes Dokument behält kein Original und sagt das.** Der Upload-Weg speichert eine Kopie unter `rag/{collection}` und ein Sync nicht: die Bytes einer synchronisierten Datei liegen in dem System, aus dem sie kam, und jede einzelne davon auf die Platte dieses Deployments zu spiegeln, damit eine Schaltfläche funktioniert, sind Kosten pro Korpus statt pro Fehlschlag. Also ist `storage_path` für diese leer und `has_file` falsch, und genau das muss eine Oberfläche lesen, die einen Download anbietet. **Den Sync erneut auszuführen ist der erneute Versuch** — seit [#990](https://github.com/vstorm-co/agenticos/issues/990) überspringt er alles Unveränderte und holt genau das erneut, wozu es kein Dokument gibt, vier Fehlschläge von vierzig zu wiederholen kostet also vier Übertragungen statt vierzig. **Jeder Weg öffnet die Zeile, bevor die Datei indexiert wird.** Danach geschrieben, ließ eine Zeile, deren Schreiben scheiterte — ein Datenbankaussetzer, ein Name, der länger ist als die Spalte — das Vektordokument gespeichert und unverfolgt zurück. Der nächste `new_only`-Durchlauf traf dann seinen Hash und *übersprang* die Datei, bevor er das Schreiben erreichte, sie blieb also durchsuchbar, unsichtbar und für immer unlöschbar. Der schlimmste Fall dieser Reihenfolge ist eine Zeile, die `processing` sagt, neben einem Dokument, das fertig ist, und das ist sichtbar und lässt sich löschen. Der Connector-Sync hörte in [#992](https://github.com/vstorm-co/agenticos/issues/992) auf, danach zu schreiben, und der für lokale Verzeichnisse in [#997](https://github.com/vstorm-co/agenticos/issues/997) — was einer lokal synchronisierten Datei, die sich nicht parsen lässt, auch eine Zeile und einen Grund gab. Sie hatte keines von beidem, ein Sync-Log, das meldete, vier von vierzig seien gescheitert, nannte also keine davon. **Eine synchronisierte Zeile sagt, welche Datei sie verfolgt**, in `source_path`: `gdrive://`, `s3://bucket/key`, oder ein absoluter Pfad bei einem lokalen oder CLI-Sync. Das ist es, was einen früheren Versuch an *derselben Datei* außer Dienst setzt — ein gescheitertes Parsen schreibt keine Vektoren, die Außerdienststellung in `complete_ingestion` hat also nichts zum Abgleichen, und früher überlebten beide Zeilen, eine mehr pro Fehlschlag, jede zählte in den `document_count` der Collection ([#996](https://github.com/vstorm-co/agenticos/issues/996)). **Ein Upload speichert keine Adresse** und setzt deshalb nichts außer Dienst. Sein einziger Name ist ein Basisname, und der ist keine Adresse: zwei Menschen können verschiedene `report.pdf` hochladen und, bei `replace=false`, meinen, dass beide existieren sollen. Nach diesem Namen außer Dienst zu stellen würde die gescheiterte Zeile der ersten löschen — ihre Diagnose, ihren erneuten Versuch und ihre gespeicherte Datei — für einen Aufrufer, der um nichts dergleichen gebeten hat. Eine Adresse `NULL` trifft auf keinen Vergleich zu, und das ist die gewünschte Antwort und keine, die zu umgehen wäre, und es ist das, was jede vor der Spalte geschriebene Zeile hält. Vier Dinge entscheiden, was eine Außerdienststellung mitnehmen darf, und jedes davon war zuerst falsch: - **Nach Adresse, nie nach Dateiname.** Das ist die Kollision, die [#990](https://github.com/vstorm-co/agenticos/issues/990) auf der Vektorseite beseitigt hat, aus der anderen Richtung erreicht: `a/readme.md` und `b/readme.md` in einem Bucket teilen sich einen Basisnamen, ein Namenstreffer löscht also die Zeile der anderen Datei. - **Im Bereich der neuen Zeile, nie nach Collection-Namen.** Ein Name ist nicht eindeutig, also können zwei Organisationen oder zwei Basen einer Organisation denselben Pfad hinein synchronisieren. Eine einer Basis zugeordnete Zeile nimmt nur die Zeilen, die diese Basis auflistet - ihre eigenen und die keiner Basis zugeordneten ihres Tenants -, eine Zeile ohne Basis nur die Zeilen ohne Basis ihrer eigenen Organisation ([#2747](https://github.com/vstorm-co/agenticos/issues/2747)). - **`ERROR`, nicht "hat keine Vektor-Id".** Das sind verschiedene Mengen, und sie als eine zu behandeln ist eine Race Condition: eine `PROCESSING`-Zeile gehört zu einem noch laufenden Versuch, und bei zwei überlappenden Ingestionen einer Quelle würde die zweite die lebende Zeile der ersten löschen — woraufhin die erste fertig wird, die Vektoren ersetzt und keine Zeile zum Abschließen findet. - **Ein gescheitertes *Ablösen* ist keine gescheiterte Ingestion.** `ingest_file` fügt das neue Dokument ein, bevor es das ersetzte löscht, ein Löschen, das eine Ausnahme warf, lieferte also früher einen Fehler zurück, während die Vektoren dalagen — eine `ERROR`-Zeile ohne Vektor-Id, die der nächste Versuch außer Dienst gestellt und damit die Vektoren verwaist hätte. Dass das Einfügen geklappt hat, ist die ganze Antwort: das verbliebene alte Dokument wird protokolliert, und ein Duplikat, das jemand sehen und löschen kann, ist kein zu meldender Fehlschlag. Der Connector-Sync schrieb bis [#992](https://github.com/vstorm-co/agenticos/issues/992) überhaupt keine Zeile — der Satz oben galt nur für den Upload, die CLI und den *lokalen* Sync. Ein Dokument aus einem Drive-Ordner war durchsuchbar und unsichtbar: nicht im Tab Documents der Wissensdatenbank (`GET /kb/{kb_id}/documents` liest `get_for_kb`), nicht im eigenen `document_count` der Collection, per Löschen nicht erreichbar, und ein Fehlschlag war eine Zahl im Sync-Log ohne einen Grund pro Datei irgendwo. Gescheiterte Ingestionen lassen sich über `POST /rag/documents/{id}/retry` erneut versuchen. Es liest `storage_path` neu — die Kopie, die der Upload genau dafür behalten hat — und stößt das Parsen erneut an, wobei es ersetzt, was der gescheiterte Versuch indexiert hat. Ein Dokument, das nicht gescheitert ist oder das keine gespeicherte Datei hat — eines, das älter ist als das Aufbewahren durch Uploads, oder eines, das ein Sync eingelesen hat — wird mit einer 400 abgelehnt, statt nach `processing` versetzt zu werden ([#441](https://github.com/vstorm-co/agenticos/issues/441)). Der Retry lehnt auch ein Dokument einer Organisation ab, das keiner Wissensbasis zugeordnet ist: Seine Chunks würden deploymentweit geschrieben, wo die Basis der Organisation, die es listet, nicht liest, also muss es erneut hochgeladen werden. Eines ohne Organisation gehört einer app-scoped Basis, die dort liest, und wird erneut versucht ([#2568](https://github.com/vstorm-co/agenticos/issues/2568)). ### Was eine gescheiterte Ingestion sagt { #what-a-failed-ingest-says } `error_message` ist eine gespeicherte Spalte, dargestellt auf der Dokumentseite und in der Sync-Historie einer Source, für jeden, der die Collection sehen kann. Sie trägt deshalb eine Zusammenfassung statt dessen, was der gescheiterte Client zufällig gesagt hat: ``` The document could not be indexed (AuthenticationError) - check the collection's embedding credential, then retry the upload. The worker log has the full error. ``` Drei Teile, und jeder ist aus einem Grund da. **Die Phase** — Parsen, Indexieren, das Ergebnis festhalten, oder ein ganzer Sync — ist das eine, was die lesende Person hinterher nicht herausfinden kann, und sie trennt eine Datei, die der Parser dieser Collection nicht liest, von Zugangsdaten, die der Provider abgelehnt hat. **Der Typ der Ausnahme** wird behalten, weil ein Klassenname ein Symbol ist: er sagt, dass die Zugangsdaten abgelehnt wurden oder die Gegenstelle in eine Zeitüberschreitung lief, ohne den Host zu nennen, der das gesagt hat. **Der Rat** ist das, was die lesende Person tatsächlich tun kann. Ein Fehlschlag wird von bis zu drei Handlern gemeldet — der Phase, die ihn ausgelöst hat, der Prüfung, dass ein zurückgegebener Fehlschlag nicht `done` ist, und dem Auffangnetz des Flows — und der **erste**, der ihn festhält, behält die Zeile, denn er ist der innerste und der genaueste. Ein erneuter Versuch löscht die Meldung, damit der nächste Versuch seine eigene festhält. Eine Ablehnung, die diese Plattform selbst ausgesprochen hat, wird stattdessen unverändert durchgereicht, denn ihre Meldung ist hier geschrieben und das Nützlichste, was sich zeigen lässt: *"No embedding credential is configured for this collection"*, *"Organization monthly budget exhausted: $40.15 spent of $40.00 limit"*. **Nicht** gespeichert wird der eigene Text des gescheiterten Clients. Ein Provider-SDK, `httpx`, `boto3` und der Google-Drive-Client legen alle die Anfrage, die sie gerade machten, in ihre Ausnahmemeldung, und das heißt regelmäßig ein Endpunkt, ein interner Host, ein Bucket oder eine URL mit einem Schlüssel im Query-String — und anders als ein HTTP-Fehlerkörper wird eine Spalte Wochen später erneut gelesen, von jedem, der das gescheiterte Dokument öffnet. Dieser Text ist nicht verloren: jede dieser Aufrufstellen protokolliert ihn mit `logger.exception`, das Worker-Log hat also Meldung und Traceback, und ein Job, der erneut wirft, hat Typ, Meldung und letzte Frames in seiner Zeile in `background_jobs`, wo nur der Eigentümer des Deployments sie liest. `app/services/rag/failures.py` ist die Stelle, an der Spalte und Log getrennt werden. Das Log ist ein kleineres Publikum als die Spalte, kein sicheres — behandeln Sie ein Worker-Log als etwas, das nur Betreiber lesen, und siehe [#440] dafür, warum der Redaction-Filter, den dieses Deployment mitliefert, es derzeit nicht säubert. [#440]: https://github.com/vstorm-co/agenticos/issues/440 ### Sync-Vorgänge { #sync-operations } Sync-Vorgänge werden über das Modell `SyncLog` verfolgt, das Source, Modus, Gesamtzahl der Dateien, die Zähler für eingelesen/aktualisiert/übersprungen/gescheitert und die Zeiten festhält. Die Sync-Historie sehen Sie über `GET /rag/sync/logs`. **Welchem gespeicherten Dokument eine Datei entspricht, ist eine Frage, und eine indizierte.** `IngestionService.existing_document` gibt sie an `find_existing_document` des Speichers weiter, das das Dokument einen Metadatenschlüssel nach dem anderen nachschlägt — `source_path`, dann einen `filename`, unter dem das Dokument nicht mit einem anderen Pfad adressiert ist, dann `content_hash` — in dieser Reihenfolge und beim ersten Treffer endend. Es antwortet mit der Id des Dokuments **und** seinem gespeicherten `content_hash`, und die beiden kommen absichtlich zusammen zurück: sie sind Tatsachen über *ein* Dokument. Von getrennten Abfragen mit unterschiedlichen Regeln berechnet könnten sie sich widersprechen, und so verglich ein Sync den Hash einer lebenden Datei mit dem eines anderen Dokuments und bettete entweder jede Nacht eine unveränderte Datei neu ein oder übersprang eine geänderte als aktuell ([#548](https://github.com/vstorm-co/agenticos/issues/548)). `PgVectorStore` bedient jede Abfrage aus einem **Hash**-Index auf diesem Metadatenschlüssel. Hash statt Btree, denn die Abfragen sind reine Gleichheitsabfragen und ein `source_path` ist unbegrenzt — ein Btree würde an seiner Zeilengrößengrenze scheitern und die Ingestion mitnehmen. Die Indizes werden mit der Laufzeittabelle angelegt und von der Migration `0058_backfill_rag_lookup_indexes` auf ältere Collections nachgetragen. Das macht die Prüfung zu einer Handvoll indizierter Statements, statt zu dem Lesen der ganzen `rag_`-Tabelle in den Speicher des Workers, das sie früher war — einmal pro eingelesenem Dokument, auf einer Collection, die hunderttausende Chunks halten kann ([#1102](https://github.com/vstorm-co/agenticos/issues/1102), die Ingestion-Hälfte von [#27](https://github.com/vstorm-co/agenticos/issues/27); die andere Hälfte hat die Auflistung der verfolgten Dokumente paginiert). Ein Rückfall in der Basisklasse antwortet weiterhin, indem sie die Auflistung liest, für einen Speicher, der sich auf keinen Index stützen kann. `new_only` überspringt eine Datei, deren gespeicherter Hash übereinstimmt, `update_only` überspringt eine unveränderte und ignoriert eine neue, und `full` ersetzt, worauf es auch trifft. Ein Speicher, der die Auflistung nicht beantworten kann, wird als "kein Treffer" behandelt statt als Treffer: eine gescheiterte Abfrage ist kein Beleg dafür, dass ein Dokument fehlt, aber so zu handeln, als *wäre* ein Dokument da, würde eines löschen. **Beide Flows, und sie müssen übereinstimmen.** Eine `sync_mode`-Spalte speist ein lokales Verzeichnis und einen Connector gleichermaßen, ein Modus, der für jeden etwas anderes bedeutet, ist also der Defekt, was einer von beiden allein auch tut. Ein Connector-Sync setzte bis [#990](https://github.com/vstorm-co/agenticos/issues/990) nichts davon um. `sync_mode` erreichte nur das `replace`-Argument von `ingest_file`, und `ingest_file` überspringt nie — beim voreingestellten `new_only` wurde das vorherige Dokument also weder gefunden noch gelöscht, und bei jedem Durchlauf wurde eine **zweite Kopie** eingefügt. Eine Woche nächtlicher Syncs waren sieben Kopien jedes Chunks, in jeder Suche gegeneinander bewertet und jede einzelne in Embeddings bezahlt. Der Zähler `skipped` daneben wurde initialisiert und nie erhöht, und das ist ein Sync-Log, das jede Nacht wahrheitsgemäß `skipped=0` meldet. Wo die Entscheidung fällt, unterscheidet sich zwischen ihnen, denn die Bytes einer entfernten Datei kosten etwas beim Holen. `update_only` braucht keine Bytes, um eine Datei zu überspringen, die es nie gesehen hat, diese Antwort wird also vor dem Download gegeben; ein Hash braucht sie, eine unveränderte Datei wird also nach einem Download und vor dem Embedding erkannt, das die teure Hälfte ist. Ein gespeichertes Dokument ohne `content_hash` wird neu eingelesen, statt als aktuell angenommen zu werden: eine Datei zu überspringen, die sich geändert haben könnte, ist die Antwort, die nichts später korrigiert. Eine ersetzte Datei wird als **Aktualisierung** gezählt statt als Ingestion, abgelesen an `replaced_document_id` statt am eigenen Satz des Ergebnisses. **Zwei Dinge zum Abgleich, und beide entscheiden, ob ein Dokument überlebt.** Das vorletzte Mittel von `existing_document` ist ein Treffer auf den *Dateinamen*, und es existiert, damit eine über den Browser hochgeladene und später aus dem Ordner, aus dem sie kam, synchronisierte Datei ersetzt statt dupliziert wird — ein Upload speichert seinen Dateinamen als `source_path`, die beiden stimmen also überein und sie bleibt über den Namen erreichbar. Ein Dokument, das eine **andere** Adresse nennt, kommt dafür nicht in Frage. In einem Bucket mit `a/readme.md` neben `b/readme.md` fand der zweite Schlüssel das Dokument des ersten über den Namen, gleicher Inhalt übersprang es also und ungleicher Inhalt ersetzte das erste — so oder so konnte ein erster Sync nicht beide behalten, und er sagte nichts. Dieselbe Kollision galt für zwei lokale Dateien eines Namens in verschiedenen Verzeichnissen. Und ein Ersatz **fügt ein, bevor er löscht**. In `insert_document` werden die Embeddings berechnet, ein Provider, der zwischen den beiden Statements ablehnte, ließ die Collection also früher mit keinem der beiden Dokumente zurück — dauerhaft, denn eine gescheiterte Ingestion wird zurückgegeben statt geworfen, und nichts versucht sie erneut. Beide für die Dauer eines Einfügens ist ein Zustand, den eine Suche übersteht; keines nicht. Die eigene Historie einer Source ist `GET /kb/{kb_id}/sync-sources/{source_id}/logs`. Die Source wird zuerst gegen diese Wissensdatenbank aufgelöst, eine Source, die zu einer anderen Wissensdatenbank gehört, antwortet also mit **404** statt mit einer leeren Liste — die beiden stellen sonst denselben Bildschirm dar, und eine davon ist eine Anfrage, die hätte scheitern sollen. Ihre Durchläufe werden dann nach Source-Id gelesen, und das ist es, was `limit` und `total` dieselbe Menge von Zeilen beschreiben lässt: eine auf eine andere Wissensdatenbank umgehängte Source behält ihre früheren Durchläufe unter dem Collection-Namen, den sie damals hatte, und die fielen früher von der Seite, nachdem `limit` sie bereits beschnitten hatte. ### Was eine Sync-Source nicht entscheiden darf { #what-a-sync-source-is-not-allowed-to-decide } !!! danger "Wer eine Datei in einen geteilten Ordner legen kann, wählt die Zeichenkette, die der nächste Sync verarbeitet" Zwei dieser Zeichenketten wurden früher für bare Münze genommen: ein Dateiname, der ein Pfad war (`../../../../home/app/.ssh/authorized_keys` ist ein zulässiger Drive-Name), und eine Ordner-Id, die die Abfragesprache von Drive erreichte. `remote_names.py` lehnt beides ab, und `BaseSyncConnector` - kein Connector - entscheidet, wo ein Byte landet, ein später hinzugefügter Connector erbt die Ablehnung also, statt sich an sie erinnern zu müssen. Der Inhalt einer Source gehört nicht dem Deployment zum Vertrauen, und bei einem außerhalb der Organisation geteilten Drive-Ordner nicht einmal dem Tenant: Teilen ist das, *wofür* das Teilen von Ordnern da ist. !!! danger "Ein Dateiname ist eine Beschriftung, keine Pfadkomponente" `../../../../home/app/.ssh/authorized_keys` ist ein zulässiger Dateiname in Drive, und der Connector schrieb `dest_dir / file.name` wörtlich — außerhalb des temporären Verzeichnisses, das der Worker angelegt hatte, überall dorthin, wo seine uid schreiben konnte, und las dann von dort ein. Der Name wird jetzt auf seine letzte Komponente reduziert, und das Ergebnis wird *aufgelöst und bestätigt* als Kind des Sync-Verzeichnisses. So sind `..`, seine Kodierungen, seine Doppelgänger und ein bereits im Verzeichnis liegender Symlink **eine Frage** statt einer Liste von Schreibweisen, mit der man Schritt halten muss. Ein Name, der überhaupt keine Komponente ist — `..`, `.`, `/` — wird abgelehnt. Alles andere landet als eine Datei darin. **Das Ziel ist die Antwort von `BaseSyncConnector`, nicht die eines Connectors.** Eine Implementierung bekommt einen Pfad übergeben und schreibt darauf (`_fetch`), und das ist es, was einen später hinzugefügten Connector die Ablehnung erben lässt, statt sich an sie erinnern zu müssen. **Eine Ordner-Id erreicht eine Abfragesprache.** Die Drive-Abfrage umschließt eine Eltern-Id mit einfachen Anführungszeichen, `x' in parents or name contains 'salary` ist also eine wohlgeformte, weitere Abfrage. Eine Ordner-Id wird jetzt gegen das geprüft, was Google ausgeben kann — Buchstaben, Ziffern, `-` und `_` — und zwar dort, wo die Abfrage gebaut wird, und das ist der eine Trichter, durch den sowohl der konfigurierte Ordner als auch jede Unterordner-Id läuft. `validate_config` stellt dieselbe Frage, ein feindseliger Wert wird also von der Route beantwortet, die ihn angenommen hat, und nicht von einem Sync-Log eine Stunde später. **Eine Google-Drive-Source läuft mit ihren eigenen Zugangsdaten oder gar nicht.** Der Connector fiel früher auf `GOOGLE_DRIVE_CREDENTIALS_FILE` zurück, sobald `service_account_json` fehlte, und das hieß, dass die Ordner-Id eines Tenants wählte, was unter dem Service-Account des *Betreibers* aufgelistet war und was immer mit diesem Account geteilt worden war. Der Rückfall ist weg; die Einstellung bedient jetzt nur noch den CLI-Befehl `rag-sync-gdrive`, den ein Betreiber aus seiner eigenen Shell ausführt. ### Die Zugangsdaten sind ein Vault-Secret, kein Konfigurationsfeld { #the-credential-is-a-vault-secret-not-a-config-field } !!! danger "Zugangsdaten gehören nie in das `CONFIG_MODEL` eines Connectors" `sync_sources.config` sagt, wie die Dokumente zu *finden* sind. Was authentifiziert, ist ein Vault-Secret, das die Source in `secret_id` nennt - und es gibt keinen deploymentweiten Rückfall, denn ein Rückfall bedeutet, dass die Ordner-Id eines Tenants wählt, was unter der Identität des Betreibers gelesen wird. Was die Source in `secret_id` nennt, ist ein `gcp_service_account` für Drive, ein `aws_credentials`-Paar für S3 oder ein `git_token` für ein Git-Repository, vom Connector als `SECRET_KIND` deklariert und dem Assistenten als `secret_kind` in der Connector-Auflistung angeboten. Früher stand es in `config`, verschlüsselt von `app/core/crypto.py` — ein deploymentweiter Fernet-Schlüssel über den Zugangsdaten jedes Tenants, und das ist genau die Schwäche, die der Vault beseitigen soll, und die eine Stelle, an der "es gibt kein zweites Verfahren" aus `CLAUDE.md` unwahr war. Dieses Modul ist weg ([#937](https://github.com/vstorm-co/agenticos/issues/937)). Daraus folgen drei Dinge: - **Zugangsdaten werden einmal hinzugefügt und referenziert.** Fünf Wissensdatenbanken, gespeist aus einem Drive-Ordner, hießen früher, dass dasselbe JSON fünfmal eingefügt, fünfmal rotiert und an fünf Stellen zurückgezogen wurde. Eine Integration zu klonen kopiert jetzt die Referenz. - **Der Assistent bietet an, was die Organisation hält**, gefiltert auf die Art, die der Connector braucht, und verlinkt auf den Vault, wenn es nichts gibt — `InlineSecret` wird hier nicht verwendet, weil es nur `api_key` behandelt, und ein Service-Account ist ein mehrfeldriges Formular, dessen ehrlicher Platz der Vault ist. - **Der Service lehnt eine Konfiguration ab, die Zugangsdaten trägt.** Die alten Feldnamen zu posten wird mit "a credential does not go in a source's configuration" beantwortet, statt verworfen zu werden, sodass die Source gespeichert wird und sich dann nicht authentifizieren kann. Gelesen wird es dort, wo es eine Session und einen Tenant gibt: der Worker entsiegelt das Secret für die eigene Organisation der Source und übergibt es dem Connector neben der Konfiguration. Ein Connector kann den Vault nicht selbst erreichen, und eine Source, deren Secret gelöscht wurde, synchronisiert nicht weiter — die Connectors haben keinen deploymentweiten Rückfall und dürfen keinen bekommen. ### Wer am Ende lesen kann, was eine Source eingelesen hat { #who-ends-up-able-to-read-what-a-source-ingested } **Die Collection ist die Berechtigungsgrenze, und die Reichweite einer Source ist die Berechtigung ihrer Zugangsdaten, verengt durch ihre eigene Konfiguration.** Eine Sync-Source liest in genau eine Collection ein, der Zugriff wird an der Collection entschieden (siehe [Wer eine Collection erreichen darf](#who-may-reach-a-collection)), und es gibt keine Isolierung pro Dokument innerhalb einer — also **wird alles, was diese Source liest, lesbar für jeden, der diese Collection lesen kann.** Die beiden Hälften dieser Reichweite sind nicht gleich verlässlich, und das ist der Teil, den man wissen sollte. Eine Drive-Source ist durch ihre `folder_id` begrenzt und eine S3-Source durch ihren `bucket` und `prefix`, weit reichende Zugangsdaten, die auf einen Ordner gerichtet sind, lesen also einen Ordner ein. Aber `config` ist ein Feld auf der Zeile, bearbeitbar von jedem, der `collections:edit` auf dieser Collection hält. !!! warning "Die Konfiguration verengt die Reichweite, und man kann sich nicht darauf verlassen, dass sie sie eng hält" Die Berechtigungen der Zugangsdaten selbst sind eine Obergrenze, die nichts in diesem Produkt anheben kann. Ein Confluence-Token, das für die ganze Instanz gilt, auf einer Source, die jemand später auf einen weiteren Space richtet, veröffentlicht die ganze Instanz an jedes Mitglied, das `collections:view` hält. Dasselbe Token, auf einen Space begrenzt, kann das nicht, was die Konfiguration auch sagt. Das ist eine Entscheidung, die jemand treffen muss, und die Antwort der Plattform ist, sie **ausdrücklich statt raffiniert** zu machen. Die Alternative — die eigenen ACLs jeder Source in den Speicher zu spiegeln und beim Retrieval zu filtern — steht nicht auf der Roadmap, und die Gründe sind es wert, genannt zu werden, damit sie nicht erneut als offensichtlicher Gewinn vorgeschlagen wird: - **Es gibt keine Identitätszuordnung.** Eine SharePoint-ACL nennt Entra-Prinzipale, eine in Confluence nennt Atlassian-Konten, und keine von beiden ist eine `organization_members`-Zeile. Die Entsprechung über die E-Mail-Adresse zu erraten ist der Weg, auf dem eine Plattform der falschen Person Zugriff auf das richtige Dokument gibt. - **Eine ACL ist ein bewegliches Ziel.** Eine in der Source geänderte Berechtigung ist hier bis zum nächsten Sync unsichtbar, eine gespiegelte ACL ist also *veraltete Autorisierung* — schlimmer als keine, weil sie wie eine Antwort aussieht. - **Ein Crawler hat überhaupt keine ACL**, und die eines Git-Repositories gehört der Hosting-Plattform statt dem Dokument. Ein Modell, das nur für zwei der in Frage kommenden Connectors funktioniert, ist nicht das Modell. Die Regel für diejenigen, die eine Source anlegen, und das, was ein Schritt im Assistenten sagen muss, lautet also: **begrenzen Sie die Zugangsdaten, nicht nur die Konfiguration.** Ein Service-Account, der in einen Ordner geteilt wurde, eine Entra-App, die für eine Site statt für einen Tenant freigegeben ist, ein Confluence-Token, das auf einen Space begrenzt ist — das ist die Hälfte der Reichweite, die eine Bearbeitung der Source nicht weiten kann. Weit reichende Zugangsdaten auf eine `personal`-Collection zu richten verengt die Lesenden, aber nicht das Eingelesene; enge Zugangsdaten auf einer `org`-Collection sind die Form, die anzustreben ist. **Wer es entschieden hat, wird festgehalten.** Eine Source anzulegen, zu klonen, umzuhängen und zu löschen schreibt jeweils einen Audit-Eintrag - `sync_source.created`, `.updated`, `.deleted` - der die handelnde Person, den Connector, die Collection und die *Id* des Secrets nennt, nie das Konfigurationsdokument. Eine Änderung, die die Source zu einer anderen Collection verschiebt, hält auch die fest, die sie verlassen hat, denn eine Umbenennung und ein Wechsel des Publikums sind sonst derselbe Eintrag. Ein Klon wird als Anlegen festgehalten, das die Zeile nennt, aus der er stammt: er richtet Zugangsdaten, die jemand bereits begrenzt hat, auf eine andere Collection, sein Publikum ändert sich also, während sich an den Zugangsdaten nichts ändert (#983). **Und es wird vorher gesagt, nicht erst hinterher.** Der letzte Schritt des Assistenten — der, der die Collection entscheidet — nennt die Zugangsdaten und das Publikum *zusammen*, denn das Paar ist die Entscheidung: > *"<credential> can read whatever it has been granted, and everything it > ingests becomes searchable in <collection> by …"* Ein Connector, der sich mit nichts authentifiziert, hat keine Zugangsdaten zu nennen, und der Satz erfindet keine. Ebenso wenig nennt er welche, deren Leser kein `secrets:view` hält. Jeder Geltungsbereich beendet diesen Satz anders — `personal` ist sein Eigentümer, `org` ist jeder, der die Collection sehen kann, `app` ist jeder im Deployment — und eine Integration, die unter keiner Wissensdatenbank abgelegt ist, sagt, dass sie noch nichts durchsuchen kann. Der Satz wartet nicht auf die *Auswahl* der Collection, die nur dort erscheint, wo es mehr als eine zur Wahl gibt. Der Fall, aus dem das gemeldet wurde, ist eine Wissensdatenbank, die genau eine anbietet, wo es nichts zu wählen gibt und die Folge dieselbe ist (#982). Das Klonen sagt es ebenfalls, und aus dem Grund oben: es ist der einzige Weg, das Publikum einer Source aus der eigenen UI dieses Produkts heraus zu ändern. Eine bestehende umzuhängen ist ein `PATCH` auf `collection_name` an `/rag/sync/sources/{id}`. Der Source-Editor im Tab **Sync** einer Wissensdatenbank sendet keines, und deren eigenes `PATCH /kb/{kb_id}/sync-sources/{source_id}` weist einen `collection_name` ab, der eine andere Datenbank nennt - das Umhängen ist also über die API und die CLI erreichbar, wo der Audit-Eintrag oben es festhält. ### Was ein neuer Connector schuldet { #what-a-new-connector-owes } Ein Connector ist `list_files` + `_fetch` + ein `CONFIG_MODEL`, und die API-Aufrufe sind der günstige Teil. `CONFIG_MODEL` ist ein Pydantic-Modell der Konfigurationsfelder; die Auflistung veröffentlicht dessen `model_json_schema()` als `config_schema`, der Assistent zeichnet das Formular also mit `SchemaForm` - dieselbe Form, die auch eine Capability veröffentlicht ([#1093](https://github.com/vstorm-co/agenticos/issues/1093)). **Ein Objektspeicher ist weniger als das**: S3, Azure Blob und GCS sind ein Connector mit drei Clients, deshalb hält `ObjectStoreConnector` die Auflistungsschleife, die Adresse `:///` und das Überspringen von Verzeichnismarkierungen, und eine Unterklasse liefert einen Client, ein `SCHEME` und die Angabe, welches `CONFIG_MODEL`-Feld den Container nennt - `bucket` für S3 und GCS, `container` für Azure. `S3Connector` ist diese Unterklasse ([#988](https://github.com/vstorm-co/agenticos/issues/988)); seine beiden Hooks sind bewusst blockierend, denn alle drei SDKs sind es, und die gemeinsame Klasse führt sie auf einem Worker-Thread aus. Drei Dinge sind nicht günstig, und ein Connector ohne sie ist eine Rechnung oder eine Überraschung statt einer Funktion: - **Ein Änderungssignal.** Der Sync-Weg vergleicht seit [#990](https://github.com/vstorm-co/agenticos/issues/990) eines, und was er vergleicht, ist ein `content_hash` der Bytes — was heißt, dass er eine Datei herunterlädt, um herauszufinden, dass sie unverändert war. Das spart das Embedding und nicht die Übertragung. Ein Connector, der "geändert?" *ohne* die Bytes beantworten kann, sollte das in seinem Docstring sagen — ein `delta`-Token aus Graph, die `version.number` einer Seite, eine Commit-Sha, ein HTTP-`ETag` — denn ein Signal, das der Flow vor dem Download lesen kann, ist der Unterschied zwischen einem nächtlichen Sync, der eine Auflistung kostet, und einem, der den ganzen Ordner kostet. `content_hash` ist der Rückfall dort, wo das entfernte System ehrlich keines anbietet. - **Zugangsdaten, die an der Source begrenzt sind.** Siehe den Abschnitt oben. Das `SECRET_KIND` eines Connectors sagt, welche Form die Zugangsdaten haben; nichts in der Plattform kann sagen, wie weit sie ausgestellt wurden, und deshalb gehört die Anleitung dorthin, wo die Source angelegt wird. - **Eine Dateizahl, über die jemand nachgedacht hat.** Die Dokumentauflistung einer Collection zu lesen ist weiterhin ein voller Scan ([#27](https://github.com/vstorm-co/agenticos/issues/27)), ein Connector, der tausende Dateien bringt, macht diese Paginierung also dringend statt ordentlich. - **Eine Auflistung, die weiß, ob sie vollständig ist.** Ein Sync entfernt die Dokumente, die seine Source früher eingebracht hat und nicht mehr auflistet, also antwortet `list_files` mit einem `RemoteListing`, dessen `complete` sagt, ob das, was es nennt, alles ist. Eine Auflistung, die zu Ende läuft oder eine Exception wirft, ist per Konstruktion vollständig. Eine, die mittendrin anhalten kann - der Web-Crawler an seiner Seitenobergrenze oder nach einer Seite mit Zeitüberschreitung -, sagt `complete=False`, und dieser Lauf entfernt nichts ([#984](https://github.com/vstorm-co/agenticos/issues/984)). Das Entfernen ist über `rag_document_claims` eingegrenzt, nicht über `source_path`: Zwei Sources können eine Collection speisen und dasselbe Dokument auflisten, deshalb beansprucht jede Source, die ein Dokument auflistet, es für sich. Eine Source, die es nicht mehr auflistet, gibt ihren Anspruch auf, und das Dokument wird erst entfernt, wenn keine Source, die die Collection speist, es noch beansprucht ([#1879](https://github.com/vstorm-co/agenticos/issues/1879)). **Ein Sync-Connector ist kein MCP-Server.** MCP ist, wie ein Agent ein Produkt *live* erreicht, mitten im Run; eine Sync-Source ist ein geplanter Massenabzug mit Änderungserkennung, dessen Ausgabe Chunks in pgvector sind. Notion als Tool ist ein MCP-Server; Notion als Korpus ist ein Connector. Mehrere Kandidaten sind ehrlicherweise beides, und die Frage, die vor dem Schreiben zu beantworten ist, lautet, welche Hälfte gebaut wird — siehe [mcp](mcp.md). Welche Connectors gebaut werden, und in welcher Reihenfolge, wird in [#938](https://github.com/vstorm-co/agenticos/issues/938) entschieden: ein Web-Crawler ([#984](https://github.com/vstorm-co/agenticos/issues/984), ausgeliefert als der `web`-Connector - siehe [eine Website einrichten](howto/configure-sync-sources.md#website-setup)), SharePoint und OneDrive ([#985](https://github.com/vstorm-co/agenticos/issues/985), ausgeliefert als der `sharepoint`-Connector - siehe [SharePoint und OneDrive einrichten](howto/configure-sync-sources.md#sharepoint-and-onedrive-setup)), Confluence ([#986](https://github.com/vstorm-co/agenticos/issues/986)), die Dokumentation eines Git-Repositories ([#987](https://github.com/vstorm-co/agenticos/issues/987)), und dann Azure Blob und GCS, deren Bedingung erfüllt ist: `S3Connector` ist eine Unterklasse von `ObjectStoreConnector`, jeder davon ist also ein Client und ein `CONNECTOR_TYPE` statt einer zweiten Kopie der Auflistungsschleife ([#988](https://github.com/vstorm-co/agenticos/issues/988)). Gegen Notion, Slack und E-Mail-Archive ist vorerst **entschieden**, jeweils aus einem dort festgehaltenen Grund — die letzten beiden, weil eine Unterhaltung sich schlecht abrufen lässt und die Kanal-Integrationen einen Agent bereits *in* Slack setzen. ### Die Ablehnung eines Connectors nennt das Feld, um das es geht { #a-connectors-refusal-names-the-field-it-is-about } `validate_config` antwortet mit einem `ConfigRefusal` — einem Satz und dem Feld, um das dieser Satz geht — oder mit `None`, wenn die Konfiguration akzeptabel ist. Der Connector nennt sein eigenes `CONFIG_MODEL`-Feld; `SyncSourceService` verwurzelt das gegen das Dokument, das der Assistent gepostet hat (`folder_id` → `config.folder_id`), und wirft es mit `refused_field`, es erreicht den Browser also als `details["fields"]` in der einen Form, die ein Formular liest (`app/core/field_errors.py`), und der Konfigurationsschritt markiert das Eingabefeld, das der Connector abgelehnt hat. Früher antwortete es mit `(bool, str | None)`, und ein Flag mit einem Satz kann nicht sagen, *welches von vier Eingabefeldern* falsch war. Die Prüfung der Ordner-Id oben wusste es, die lesende Person nicht: der Assistent zeigte eine Zeile Prosa unter vier Kästchen. Ein Feld zu nennen ist optional, und das ist Absicht. Ein Connector darf eine Konfiguration ablehnen, ohne einen Teil davon dafür verantwortlich zu machen — eine Verbindung, die scheitert, zwei Zugangsdaten, die nicht zum selben Konto gehören — und `ConfigRefusal(message=...)` ohne Feld ist dort die ehrliche Antwort. Einen Feldnamen zu erfinden würde jemanden losschicken, einen Wert zu bearbeiten, der angenommen wurde. `checked_drive_folder_id` nennt aus demselben Grund keines: es beantwortet drei Senken, und nur einer davon wurde ein Formular zum Markieren gesendet. ### Bildbeschreibung { #image-description } Bei der Verarbeitung von Dokumenten, die Bilder enthalten, kann das System Bilder optional mithilfe der Bildfähigkeiten eines LLM beschreiben. Die Bildbeschreibung ist eine Einstellung pro Collection: schalten Sie sie in der Ingestion-Konfiguration der Wissensdatenbank ein und wählen Sie dort ein bildfähiges Modellprofil. Die Auswahl ist dieselbe, die der Agent-Builder nutzt, ein Provider, ein Modell und sein Schlüssel lassen sich also festlegen, ohne den Dialog zu verlassen — ein Deployment ohne Modellprofile ist keine Sackgasse. Was sie nicht anbietet, ist das Löschen eines Profils: das gehört dorthin, wo die Modelle einer Organisation verwaltet werden, und ein Profil, das Agents nutzen, lässt sich gar nicht löschen. PyMuPDF überspringt die Extraktion eingebetteter Bilder, wenn die Bildbeschreibung ausgeschaltet ist. Ist sie eingeschaltet, wird jeder Bild-xref nur einmal pro Dokument extrahiert; wiederholte Verweise auf einer Seite werden zusammengefasst und identische Bildbytes nur einmal beschrieben. Jede Seite mit einem Bildverweis erhält dessen Beschreibung im Text für die semantische Suche. Chunks enthalten diesen Text, ohne die eingebetteten Bilder der Seite zu kopieren. ## Aus einem Kanal { #from-a-channel } Eine Datei, die an einen Slack-, Teams-, Telegram- oder Mattermost-Bot gesendet wird, kommt hier herein, nicht daneben. Der Adapter holt sie mit den eigenen Zugangsdaten des Bots, sie durchläuft dieselbe Validierung wie ein Upload aus dem Browser, und sie wird dieselbe `ChatFile`-Zeile — das Routing oben gilt also unverändert, und ein Kanal kann nicht zum nachsichtigen Weg werden. Was sich unterscheidet, ist nur, wie eine Ablehnung aussieht: es gibt kein Formular, in dem ein Fehler gezeigt werden könnte, eine Datei, die zu groß war oder einen nicht unterstützten Typ hatte, wird also in der Antwort des Bots benannt. Siehe [Kanäle](channels.md#files). ## Zusammenfassung { #recap } - Ein Upload antwortet mit **202** und wird im Worker indexiert, mit `spawn_after_commit` übergeben, damit die Zeile dauerhaft ist, bevor etwas nach ihr sucht. - Parsen und Byte-I/O laufen auf einem **dedizierten, begrenzten Pool**, nie auf dem gemeinsamen Executor, der auch das Passwort-Hashing trägt. - **Eine Tabelle pro Collection**, zur Laufzeit angelegt, in Alembic von nichts besessen — und der Name muss frei sein, denn der Vektor-Namensraum gilt für das ganze Deployment. - Jeder Worker-Flow baut und **verwirft seine eigene Engine**. Ein Verbindungsfehler mitten in einem großen Stapel hat diese Form. - **Zugangsdaten sind eine Obergrenze; die Konfiguration ist es nicht.** Ein weit reichendes Token an eine Collection zu binden veröffentlicht alles, was es erreichen kann, an jeden, der die Collection sehen kann.