Semantisches Dateisystem

Technische Architektur der semantischen Dateiablage von Owlat — heute Versionsverfolgung mit Provenienz, dazu die geplante Schicht für Embedding-basiertes Retrieval und automatisches Tagging.

Architektur des semantischen Dateisystems

Owlat speichert bereits heute Medien-Assets für E-Mail-Kampagnen. Das semantische Dateisystem erweitert das zu einer breiteren organisatorischen Dateischicht — in der Dateien Zusammenfassungen, automatische Tags, Embeddings und eine konversationsverknüpfte Provenienz erhalten. Die Vision: Dateien werden Teil des Agentenkontexts und tauchen automatisch auf, wenn sie relevant sind.

Das meiste davon ist heute gebaut: die Tabelle semanticFiles, CRUD-Funktionen in apps/api/convex/semanticFiles.ts, Volltextsuche, Versionshistorie und eine Dateien-UI im Dashboard (apps/web/app/pages/dashboard/files/, dazu ein Dateien-Tab an jedem Kontakt). Die KI-Verarbeitungsstrecke (Textextraktion, Zusammenfassung, automatische Tags, Embeddings) ist inzwischen angebunden und eingeplant — die Mutation create stößt processFile an, echte Vektorsuche läuft über den Index vector_files, und der Schritt context_retrieval des Agenten zieht relevante Dateien in sein Briefing. Eingehende E-Mail-Anhänge werden automatisch aufgenommen: mail.delivery.ingestFromWebhook holt jedes Anhang-Blatt aus der zugestellten .eml und ruft den Einstiegspunkt semanticFiles.ingest mit sourceType: 'email_attachment' auf. Was noch fehlt: ein Erzeuger agent_generated (der Einstiegspunkt ingest akzeptiert diesen Quellentyp bereits — nur emittiert noch kein Agenten-Flow Dateien), echte Extraktion für Nicht-PDF-Binärdateien (Word/Excel/Bilder) sowie Vorschläge zum Zusammenführen von Tags. Jeder Punkt wird unten an Ort und Stelle benannt.

So funktioniert es

Dateien gelangen über drei Quellentypen ins System (das Feld sourceType):

  1. Direkter Upload (upload) — über die Dateien-UI im Dashboard.
  2. E-Mail-Anhang (email_attachment) — der vorgesehene Weg für eingehende Anhänge.
  3. Agenten-generiert (agent_generated) — Artefakte, die ein Agent erzeugt.
Heute vs. geplant

Der direkte Upload ist über das Dashboard durchgängig angebunden und durchläuft inzwischen die vollständige Verarbeitungsstrecke. Der gemeinsame Helper insertSemanticFile (semanticFiles.ts) unterstützt alle drei Quellentypen, fügt aber nur die Zeile ein (und synchronisiert Kontakte). Zwei Einstiegspunkte planen processFile ein: die nutzerseitige Mutation create (upload) und die interne Mutation ingest (email_attachment / agent_generated). Eingehende Anhänge fließen inzwischen automatisch — mail.delivery.ingestFromWebhook extrahiert jeden Anhang aus der rohen .eml und ruft ingest auf, sodass sie in der Dateibibliothek und in der Strecke Datei→Wissen landen. Die verbleibende Lücke ist die Quelle agent_generated: ingest akzeptiert sie, aber noch emittiert kein Agenten-Flow Artefakte.

Die Verarbeitungsstrecke (processFile in apps/api/convex/semanticFileProcessing.ts) läuft für jede neue Datei:

File uploaded / received
  → Store binary in Convex _storage
  → Extract text content (by MIME type):
    - text/*, application/json → raw text
    - text/html → tags stripped
    - text/csv (or .csv) → raw text
    - PDF → real text extraction via unpdf (placeholder on failure)
    - Word, Excel, images → placeholder string ([Word document: name], etc.)
  → Generate title, 2-3 sentence summary, and 5-10 auto-tags via the LLM
  → Inherit context auto-tags from the thread subject + related contacts
  → Generate an embedding (text-embedding-3-small, 1536 dims)
  → Record embeddingModel + embeddingGeneratedAt for re-embedding
  → Build searchableText for full-text search
  → Patch metadata back onto the semanticFiles row
  → Feed real extracted text into the knowledge graph (extractFromFile)
Die Verarbeitungsstrecke ist jetzt eingeplant

processFile ist eine internalAction, die die nutzerseitige Mutation create einplant (ctx.scheduler.runAfter(0, …)), sobald eine Datei eingefügt wird. Ein Cron backfillUnprocessed (crons.ts, alle 15 Minuten) dient als Sicherheitsnetz und plant die Verarbeitung für kürzlich erstellte Dateien erneut ein, deren Embedding nie angekommen ist (z. B. verloren durch eine Deployment-Lücke). Hochgeladene Dateien tragen damit einen per LLM erzeugten Titel bzw. eine Zusammenfassung, automatische Tags, ein echtes Embedding sowie embeddingModel / embeddingGeneratedAt.

Textextraktion aus Binärdateien ist nur für PDFs aktiv

extractText() liest Klartext, JSON, HTML und CSV und extrahiert inzwischen echten Text aus PDFs mithilfe des Pakets unpdf (reines JS, serverless-tauglich); bei jedem Fehlschlag fällt es auf den Platzhalter [PDF file: contract.pdf] zurück. Word-, Excel- und Bilddateien werden weiterhin mit einem Platzhaltertext gespeichert (z. B. [Word document: report.docx], [Image: scan.png]) und stützen sich für Zusammenfassung und Embedding auf den Dateinamen sowie einen eventuell vom Nutzer vergebenen Titel — es gibt bislang weder einen DOCX/XLSX-Parser noch OCR für Bilder. Dateien, die als Platzhalter-Stub verbleiben, überspringt der Extraktionsschritt Datei→Wissen.

Retrieval

Volltextsuche

Die Stichwortsuche über searchableText ist live, über die Query search in apps/api/convex/semanticFiles.ts:

const results = await ctx.db
  .query('semanticFiles')
  .withSearchIndex('search_files', (q) => q.search('searchableText', searchQuery))
  .take(limit ?? 20)

Der Suchindex besitzt keine filterFields — Owlat betreibt genau eine Organisation pro Deployment (siehe apps/api/convex/lib/sessionOrganization.ts), es gibt also keine organizationId, nach der gefiltert werden könnte.

Semantische Suche

Echte Vektorsuche über Dateien ist live. Da Convex' Vektor-API nur in Actions verfügbar ist, läuft sie in einer internalActionsemanticSearch in apps/api/convex/semanticFileProcessing.ts. Es handelt sich um ein hybrides Retrieval: Die Action bettet den Anfragetext ein (oder nimmt ein vorberechnetes Embedding entgegen) und führt zwei überdimensioniert abgerufene Zweige aus — einen Vektorzweig über ctx.vectorSearch auf dem Index vector_files und einen Volltextzweig über ftsRankedFileIds auf search_files — und fusioniert die beiden Rankings anschließend per Reciprocal Rank Fusion (lib/rrf.ts). Die fusionierten IDs werden über getByIds zu vollständigen Dateidokumenten hydratisiert (mit Storage-URLs und einem Ähnlichkeits-_score), anhand des erforderlichen Arguments scopeToContact zur Datenisolation auf Kontaktebene nachgefiltert und schließlich auf limit zugeschnitten:

// semanticFileProcessing.semanticSearch (internalAction) — simplified excerpt:
// Over-fetch both legs so the post-fusion contact filter still has survivors.
const fetchLimit = Math.min(256, Math.max(limit * 5, 50))
const hits = await ctx.vectorSearch('semanticFiles', 'vector_files', {
  vector, // query embedding produced in the same action
  limit: fetchLimit,
})
const ftsRanked = await ctx.runQuery(internal.semanticFiles.ftsRankedFileIds, {
  queryText,
  limit: fetchLimit,
})
// Fuse the vector + full-text rankings (scale-agnostic).
const fusedIds = reciprocalRankFusion([hits.map((h) => h._id), ftsRanked])
const files = await ctx.runQuery(internal.semanticFiles.getByIds, { ids: fusedIds })
// Contact-scope AFTER fusion, then slice to `limit`.
Es gibt bewusst keine Query `semanticSearch`

Der alte Stub mit Aktualitäts-Fallback wurde entfernt. semanticFiles.ts trägt jetzt nur noch einen Kommentar, der erklärt, warum es keine semanticSearch-Query gibt: Ein Query-Kontext kann ctx.vectorSearch nicht aufrufen, eine solche Query würde also stillschweigend nach Aktualität sortierte Dateien liefern und dabei vorgeben, semantische Suche zu sein — eine Falle. Die einzige semanticSearch ist die internalAction in semanticFileProcessing.ts, die echtes ctx.vectorSearch durchführt. Der Agent und jeder echte Aufrufer semantischer Suche gehen über diese Action.

Kontextuelles Retrieval für Agenten

Jetzt angebunden

Die Agenten-Strecke durchsucht semanticFiles, wenn sie eine Nachricht bearbeitet. Der Schritt context_retrieval (apps/api/convex/agent/steps/context_retrieval/index.ts) bildet eine Anfrage aus eingehendem Betreff + Text und ruft internal.semanticFileProcessing.semanticSearch auf (begrenzt auf fileLimit, standardmäßig 3); die Treffer fließen als Briefing-Abschnitt [RELEVANT FILES] neben den Abschnitt [KNOWLEDGE] ein, den er aus internal.knowledge.retrieval.semanticSearch zieht. Beide Abfragen liegen hinter dem bestehenden Token-Budget des Schritts (Stufen normal / kompaktiert / Notfall).

Wenn der Agent also eine Nachricht bearbeitet, holt der Kontextschritt relevante Dokumente heran — Verträge, Rechnungen, Angebote —, sodass Antworten auf echten Artefakten (Dateiname, Titel, Zusammenfassung) fußen statt auf nichts. Datei- und Wissens-Retrieval laufen nur, wenn die eingehende Nachricht genug Anfragetext enthält (mehr als etwa 10 Zeichen aus Betreff + Text).

Automatisches Tagging

Jede Datei kann zwei Arten von Tags tragen:

  • Automatische Tags (autoTags) — vom LLM-Aufruf summarize während der Verarbeitung erzeugt (aus extrahiertem Text und Dateinamen) und anschließend um Kontext-Tags ergänzt, die aus der Konversation geerbt werden, in der die Datei geteilt wurde. processFile slugifiziert den Thread-Betreff und die Namen der zugehörigen Kontakte (eine Datei, die in einem Thread „Q3 Financials“ mit „Acme Corp“ abgelegt wird, erhält z. B. q3-financials und acme-corp) über slugifyTag und führt sie in autoTags zusammen.
  • Manuelle Tags (tags) — von Nutzern über die Mutation update vergeben und unverändert gespeichert.

Auch die Versionsprovenienz landet hier: Löst eine Datei eine frühere Version ab, berechnet processFile aus der Wortzahl-Differenz zum extrahierten Text der Vorgängerversion eine grobe changeSummary (z. B. „42 words added vs previous version“).

Tag-Abgleich ist nicht implementiert

Aus dem Kontext geerbte automatische Tags gibt es heute, aber es gibt weiterhin keine Häufigkeitsverfolgung für Tags und keinen Vorschlag, ähnliche Tags zusammenzuführen (z. B. q3-finances und q3-financials). Automatische Tags entstehen pro Datei zum Verarbeitungszeitpunkt; sie werden über den Korpus hinweg weder abgeglichen noch dedupliziert. Ein korpusweiter Tag-Abgleich bzw. Zusammenführungsvorschläge bleiben eine Idee für die Zukunft.

Versionsverfolgung

Dateien werden versionsverknüpft, nicht ersetzt. Jeder Upload kann eine previousVersionId referenzieren, aus der create die nächste Nummer für version ableitet:

Contract v1 (uploaded Feb 10 by Alice, threadId = "Acme negotiation")
  → Contract v2 (uploaded Feb 18 by Bob, same thread, after legal review)
    → Contract v3 (uploaded Feb 25 by Alice, final signed version)

Jede Zeile hält fest:

  • Wer sie hochgeladen hat (uploadedBy)
  • Wo — der Konversations-Thread (threadId)
  • Welche Kontakte sie betrifft (contactIds)

Das Feld previousVersionId bildet eine verkettete Liste von Versionen, die getVersionHistory durchläuft, um die vollständige Kette in der UI anzuzeigen.

Schema

Die eigentliche Tabelle liegt in apps/api/convex/schema/knowledge.ts (knowledgeTables.semanticFiles):

semanticFiles: defineTable({
  storageId: v.id('_storage'),
  filename: v.string(),
  mimeType: v.string(),
  fileSize: v.number(),
  // Semantic metadata
  title: v.optional(v.string()),
  summary: v.optional(v.string()),
  extractedText: v.optional(v.string()),
  tags: v.optional(v.array(v.string())),
  autoTags: v.optional(v.array(v.string())),
  // Provenance
  sourceType: v.union(
    v.literal('upload'),
    v.literal('email_attachment'),
    v.literal('agent_generated')
  ),
  sourceMessageId: v.optional(v.string()),
  uploadedBy: v.optional(v.string()),
  // Why/where this version was shared (JSON-stringified context blob)
  uploadContext: v.optional(v.string()),
  // Relationships
  contactIds: v.optional(v.array(v.id('contacts'))),
  threadId: v.optional(v.id('conversationThreads')),
  // Versioning
  version: v.number(),
  previousVersionId: v.optional(v.id('semanticFiles')),
  // Human-readable diff vs the previous version (text files only)
  changeSummary: v.optional(v.string()),
  // Embedding for semantic search
  embedding: v.array(v.float64()),
  // Model that produced `embedding`; re-embed when this changes
  embeddingModel: v.optional(v.string()),
  // When `embedding` was generated; used to schedule re-embedding
  embeddingGeneratedAt: v.optional(v.number()),
  // Full-text search
  searchableText: v.optional(v.string()),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_created_at', ['createdAt'])
  .index('by_thread', ['threadId'])
  .index('by_previous_version', ['previousVersionId'])
  .searchIndex('search_files', {
    searchField: 'searchableText',
    filterFields: [],
  })
  .vectorIndex('vector_files', {
    vectorField: 'embedding',
    dimensions: 1536,
    filterFields: [],
  })

Ein Feld organizationId gibt es nicht — Owlat betreibt eine Organisation pro Deployment, also filtert keiner der beiden Indizes nach Organisation. embeddingModel und embeddingGeneratedAt halten fest, welches Modell den Vektor erzeugt hat (text-embedding-3-small, die Konstante CURRENT_EMBEDDING_MODEL), damit veraltete Embeddings bei einem Modellwechsel neu erzeugt werden können.

Integration mit bestehenden Systemen

Das semantische Dateisystem ersetzt nicht die bestehende Tabelle mediaAssets (apps/api/convex/schema/templates.ts). Medien-Assets sind eigens für den E-Mail-Builder gebaut (Bilder, mit Breite/Höhe, Suche, Tagging). Semantische Dateien sind eine breitere Schicht für organisatorische Dokumente. Beide nutzen Convex _storage für Binärdaten.

Dateien im Kontext des Agenten sichtbar zu machen, funktioniert inzwischen (der Schritt context_retrieval fragt vector_files ab). Eine vereinheitlichte Sicht — Medien-Assets und semantische Dateien über eine einzige Query zu durchsuchen — bleibt das längerfristige Ziel: Heute sind die beiden Schichten getrennt. Die verbleibenden übergreifenden Teile sind die automatische Aufnahme agentengenerierter Dateien (der Einstiegspunkt ingest akzeptiert den Quellentyp, aber noch emittiert kein Agenten-Flow Dateien — eingehende E-Mail-Anhänge werden bereits automatisch aufgenommen), echte Extraktion für Nicht-PDF-Binärdateien (Word/Excel/Bilder) sowie ein korpusweiter Tag-Abgleich.

Dateien sind kein eigenes Produkt

Das Dateisystem ist eine Schicht innerhalb derselben Architektur: dasselbe Deployment, dasselbe Berechtigungsmodell (Schreibzugriffe laufen über requireAdminContext) und derselbe Convex-Storage. Siehe den Dateien-Leitfaden für die Nutzersicht und die Vision zum Wissensgraph für die verwandte Schicht typisierten Wissens.