From 980d505a41c16de98e3b59457c78c096e5017021 Mon Sep 17 00:00:00 2001 From: Ida Date: Tue, 5 May 2026 14:50:33 +0200 Subject: [PATCH] added Nyla Larsson --- .../1-plan/2026-04-formgenerator-grouping.md | 460 ++++++++++++++++++ ...-unified-knowledge-indexing-rag-concept.md | 143 +++++- d-guides/deployment/poweron-sec.kdbx | Bin 22910 -> 22334 bytes 3 files changed, 576 insertions(+), 27 deletions(-) create mode 100644 c-work/1-plan/2026-04-formgenerator-grouping.md diff --git a/c-work/1-plan/2026-04-formgenerator-grouping.md b/c-work/1-plan/2026-04-formgenerator-grouping.md new file mode 100644 index 0000000..dd88dee --- /dev/null +++ b/c-work/1-plan/2026-04-formgenerator-grouping.md @@ -0,0 +1,460 @@ + + + + +# FormGenerator: Persistente Benutzer-Gruppierung + +## Beschreibung und Kontext + +Der `FormGeneratorTable` wird auf vielen Seiten der Plattform genutzt. Nutzer sollen Einträge in benannte, rekursive Gruppen organisieren können — mit persistenter Speicherung und vollständiger Kompatibilität mit Pagination, Suche, Filter und allen Action-Buttons. + +**Kernprinzip: Grouping ist ein eingebautes Feature von `PaginationParams` und `PaginatedResponse` — kein separater Call, keine eigene Route, kein eigenes API-Modul. Der bestehende `refetch()`-Mechanismus ist der einzige Transport.** + +--- + +## Architektur-Kern: Wie es funktioniert + +### Grouping reitet auf dem bestehenden Pagination-Call + +`PaginationParams` (der JSON-Parameter jedes List-Endpoints) bekommt zwei neue optionale Felder: + +``` +saveGroupTree → wenn gesetzt: Backend speichert diesen Baum VOR dem Fetch +groupId → wenn gesetzt: Backend filtert Items auf Items dieser Gruppe +``` + +`PaginatedResponse` bekommt ein neues optionales Feld: + +``` +groupTree → aktueller Gruppen-Baum des Users für diesen Endpoint (immer mitgeliefert) +``` + +**Ein Aufruf tut damit drei Dinge auf einmal:** +1. Speichert den neuen Gruppen-Baum (wenn `saveGroupTree` gesetzt) +2. Filtert auf eine Gruppe (wenn `groupId` gesetzt) +3. Gibt aktuelle Items + aktuelle Gruppen-Baum zurück + +### Ablauf End-to-End + +``` +Seitenaufruf (erster Load): + GET /api/connections/?pagination={"page":1,"pageSize":20} + ← { items: [...], pagination: {...}, groupTree: [{id, name, itemIds, subGroups}] } + +User erstellt Gruppe (lokal sofort sichtbar, dann debounced Save via refetch): + GET /api/connections/?pagination={"page":1,"pageSize":20,"saveGroupTree":[{neuerBaum}]} + ← { items: [...], pagination: {...}, groupTree: [{neuerBaum, vom Backend bestätigt}] } + +User betritt Gruppe "Kunden" (id: "g1"): + GET /api/connections/?pagination={"page":1,"pageSize":20,"groupId":"g1"} + ← { items: [nur Items der Gruppe], pagination: {totalItems: 3, ...}, groupTree: [...] } + → Suche, Filter, Sortierung, mode=ids, mode=filterValues — alles funktioniert + innerhalb des Gruppen-Scopes, da das Backend die IN-Liste kennt +``` + +### Backend: Pro Route genau 2 Zeilen Overhead + +Der gesamte Grouping-Mechanismus ist in `routeHelpers.py` als shared Helper implementiert. Jede Route die Grouping unterstützen soll, ruft ihn auf: + +```python +# Anfang der Route-Funktion (BEVOR items gebaut werden): +groupCtx = handleGroupingInRequest(paginationParams, interface, "connections") +# → speichert saveGroupTree falls vorhanden +# → gibt groupIdItemIds zurück falls groupId gesetzt + +# Items bauen (unverändert)... + +# Falls Gruppen-Scope aktiv: Items auf Gruppe einschränken +items = applyGroupScopeFilter(items, groupCtx.itemIds) + +# Am Ende: groupTree in Response einbetten +return {**result, "groupTree": groupCtx.groupTree} +``` + +**Kein neues Route-File. Kein neues Interface-File. Keine neue URL.** + +--- + +## Betroffene Module + +- **Gateway:** + - `modules/datamodels/datamodelPagination.py` — `PaginationParams` + `groupId`, `saveGroupTree`; `PaginatedResponse` + `groupTree`; neue Klassen `TableGroupNode` + `TableGrouping` in **dieser Datei** + - `modules/interfaces/interfaceDbApp.py` — `AppObjects` um `getTableGrouping(contextKey)` + `upsertTableGrouping(contextKey, rootGroups)` erweitern; neue Tabelle `table_groupings` in `poweron_app` (auto-created) + - `modules/routes/routeHelpers.py` — `handleGroupingInRequest(paginationParams, interface, contextKey)` + `applyGroupScopeFilter(items, itemIds)` hinzufügen + - Jede List-Route die Grouping unterstützen soll: **2 Zeilen** am Anfang + **1 Feld** in der Response (`groupTree`) + +- **Frontend:** + - `FormGeneratorTable.tsx` — `groupingConfig`-Prop; interner Grouping-State; nutzt `hookData.refetch()` als einzigen Transport + - `FormGeneratorControls.tsx` — Gruppen-Toolbar-Button + - `FormGenerator/GroupingManager/GroupRow.tsx` — Gruppen-Header-Zeile (neue Komponente) + - `FormGenerator/GroupingManager/GroupingManager.tsx` — Seitenpanel (neue Komponente) + - **Kein neuer Hook, kein neues API-Modul, keine Änderungen an bestehenden Feature-Hooks** + +- **DB-Migration:** Nein (Auto-Create via DatabaseConnector) + +--- + +## Datenmodell + +### Ergänzungen in `datamodelPagination.py` + +```python +# --- Grouping-Modelle (neu, in derselben Datei) --- + +class TableGroupNode(BaseModel): + id: str + name: str + itemIds: List[str] = Field(default_factory=list) + subGroups: List['TableGroupNode'] = Field(default_factory=list) + order: int = 0 + isExpanded: bool = True + +TableGroupNode.model_rebuild() + +class TableGrouping(BaseModel): + """DB-Tabelle table_groupings in poweron_app.""" + id: str + userId: str + contextKey: str # abgeleitet aus Route-Prefix, z. B. "connections", "prompts", "admin/users" + rootGroups: List[TableGroupNode] = Field(default_factory=list) + updatedAt: Optional[float] = None + + +# --- Erweiterung PaginationParams (2 neue optionale Felder) --- + +class PaginationParams(BaseModel): + page: int = Field(ge=1) + pageSize: int = Field(ge=1, le=1000) + sort: List[SortField] = Field(default_factory=list) + filters: Optional[Dict[str, Any]] = None + # NEU: + groupId: Optional[str] = None # Scope: nur Items dieser Gruppe + saveGroupTree: Optional[List[Dict[str, Any]]] = None # Persistieren: diesen Baum speichern + + +# --- Erweiterung PaginatedResponse (1 neues optionales Feld) --- + +class PaginatedResponse(BaseModel, Generic[T]): + items: List[T] + pagination: Optional[PaginationMetadata] + groupTree: Optional[List[TableGroupNode]] = None # NEU — immer mitgeliefert wenn vorhanden + + model_config = ConfigDict(arbitrary_types_allowed=True) +``` + +--- + +## Backend-Implementierung + +### `routeHelpers.py` — neuer shared Helper + +```python +from dataclasses import dataclass +from typing import Optional, Set + +@dataclass +class GroupingContext: + groupTree: Optional[list] # Für die Response + itemIds: Optional[Set[str]] # Falls groupId gesetzt — IN-Filter-Menge + + +def handleGroupingInRequest( + paginationParams: Optional[PaginationParams], + interface, # AppObjects + contextKey: str, +) -> GroupingContext: + """ + Zentraler Grouping-Handler — aufgerufen am Anfang jeder List-Route. + + 1. Falls paginationParams.saveGroupTree gesetzt: + → interface.upsertTableGrouping(contextKey, saveGroupTree) + → saveGroupTree aus params entfernen (wird nicht weiter verarbeitet) + + 2. Falls paginationParams.groupId gesetzt: + → Gruppe im gespeicherten Baum suchen (rekursiv inkl. Subgruppen) + → itemIds der Gruppe (+ alle Subgruppen) als Set zurückgeben + → groupId aus params entfernen (wird nicht als normaler Filter verarbeitet) + + 3. Aktuellen groupTree laden und für Response bereitstellen. + + Returns: GroupingContext(groupTree, itemIds) + """ + + +def applyGroupScopeFilter(items: list, itemIds: Optional[Set[str]]) -> list: + """ + Wendet den Gruppen-Scope-Filter an. + Gibt items unverändert zurück wenn itemIds is None (kein Scope aktiv). + Filtert sonst auf item["id"] in itemIds. + """ + if itemIds is None: + return items + return [item for item in items if str(item.get("id", "")) in itemIds] +``` + +### Route-Erweiterung — Muster (2 + 1 Zeilen) + +```python +@router.get("/") +async def get_connections(request, pagination=None, mode=None, column=None, currentUser=Depends(getCurrentUser)): + from modules.routes.routeHelpers import handleGroupingInRequest, applyGroupScopeFilter + + interface = getInterface(currentUser) + CONTEXT_KEY = "connections" + + # 1. Grouping verarbeiten (speichern falls nötig, Scope auflösen) + groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY) + + # mode=filterValues / mode=ids (unverändert, aber groupId ist bereits aus params entfernt) + if mode == "filterValues": ... + if mode == "ids": + items = _buildEnhancedItems() + items = applyGroupScopeFilter(items, groupCtx.itemIds) # Scope auch für ids! + return handleIdsInMemory(items, pagination) + + # Items bauen (unverändert) + items = _buildEnhancedItems() + + # 2. Gruppen-Scope-Filter anwenden + items = applyGroupScopeFilter(items, groupCtx.itemIds) + + # Pagination (unverändert) + result = paginateInMemory(items, paginationParams) + + # 3. groupTree in Response einbetten + return {**result.model_dump(), "groupTree": groupCtx.groupTree} +``` + +**`mode=filterValues` und `mode=ids` funktionieren automatisch korrekt im Gruppen-Scope**, weil `groupId` aus `paginationParams` entfernt wurde und `applyGroupScopeFilter` aufgerufen wird — dadurch beziehen sich Filter-Dropdowns und "Select All Filtered" auf Items der aktuellen Gruppe. + +--- + +## Frontend-Implementierung + +### `FormGeneratorTable` — interner Grouping-State + +```typescript +// Neues Prop: +groupingConfig?: { + contextKey: string; // Nur für RBAC/Logging — der eigentliche Key wird serverseitig aus dem Endpoint abgeleitet + enabled: boolean; +} + +// Interner State (nur in FormGeneratorTable, nicht im Hook): +const [groupTree, setGroupTree] = useState([]); +const [activeGroupId, setActiveGroupId] = useState(null); +const [pendingGroupTree, setPendingGroupTree] = useState(null); +``` + +### Datenfluss — `hookData.refetch()` als einziger Transport + +```typescript +// groupTree kommt aus der normalen refetch-Response: +useEffect(() => { + if (hookData?.pagination?.groupTree) { + setGroupTree(hookData.pagination.groupTree); + setPendingGroupTree(null); // Gespeichert — pending löschen + } +}, [hookData]); + +// Beim Betreten eines Gruppen-Scopes: +const _enterGroup = (groupId: string) => { + setActiveGroupId(groupId); + hookData.refetch({ ...currentPaginationParams, page: 1, groupId }); +}; + +// Beim Verlassen des Scopes: +const _exitGroup = () => { + setActiveGroupId(null); + hookData.refetch({ ...currentPaginationParams, page: 1, groupId: undefined }); +}; + +// Bei Gruppen-Mutation (erstellen, umbenennen, löschen, Item zuordnen): +const _mutateGroupTree = (newTree: TableGroupNode[]) => { + setGroupTree(newTree); // Sofort lokal sichtbar (optimistic) + setPendingGroupTree(newTree); // Markiert für nächsten Save + _debouncedSave(newTree); // Debounced: nach 500ms via refetch speichern +}; + +// Debounced Save: normaler refetch + saveGroupTree im pagination param +const _debouncedSave = useMemo(() => debounce((tree: TableGroupNode[]) => { + hookData.refetch({ + ...currentPaginationParams, + saveGroupTree: tree, // Backend speichert und bestätigt + }); +}, 500), [hookData, currentPaginationParams]); +``` + +**Kein neues API-Modul. Kein eigener fetch-Call. `hookData.refetch()` ist alles.** + +### Render-Struktur + +``` +Root-View (kein activeGroupId): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▼ Kunden (3 Items) [Delete all] [Download all] [+ Subgruppe] [Umbenennen] [×] + ▶ Aktive (1) [Delete all] [+ Subgruppe] [Umbenennen] [×] + — Item A [Edit] [Delete] [Download] + — Item B [Edit] [Delete] [Download] + — Item C (Aktive) [Edit] [Delete] [Download] +▶ Intern [5 Items — Gruppe öffnen →] +── Nicht zugeordnet (2) + — Item X [Edit] [Delete] [Download] + +Gruppen-Scope (activeGroupId = "Intern"): +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +← Zurück | Intern (Seite 1/1 · 5 Einträge) [Delete all] [Download all] + — Item C [Edit] [Delete] [Download] + — Item D [Edit] [Delete] [Download] + ... +``` + +Wenn `activeGroupId` gesetzt: refetch läuft mit `groupId` → Backend filtert → Pagination, Suche, Filter, `mode=ids` — alles auf Gruppe begrenzt. + +--- + +## Pagination, Suche, Filter — vollständig korrekt + +| Szenario | Was passiert | +|----------|-------------| +| Root-View, Seite blättern | Normaler refetch. `groupTree` kommt in Response mit. Gruppen-Counter aus `groupNode.itemIds.length`. | +| Gruppen-Scope aktiv | `groupId` in PaginationParams → Backend IN-Filter → Totalcount kommt vom Backend (korrekt). | +| Suche im Scope | `groupId` + `filters.search` → Backend filtert Items der Gruppe nach Suchtext. | +| "Select All Filtered" (mode=ids) | `groupId` in params → `applyGroupScopeFilter` wird VOR `handleIdsInMemory` angewendet → nur IDs der Gruppe werden zurückgegeben. | +| Filter-Dropdown (mode=filterValues) | `groupId` in params → `applyGroupScopeFilter` vor `handleFilterValuesInMemory` → Distinct-Werte kommen nur aus Gruppen-Items. | +| Gruppen-Baum speichern während Paginieren | `saveGroupTree` + Seiten-Params im gleichen Call → Backend speichert Baum UND gibt aktuelle Seite zurück. | + +--- + +## Entscheidungen + +| Datum | Entscheidung | Begründung | +|-------|-------------|------------| +| 2026-04-29 | `saveGroupTree` + `groupId` in `PaginationParams` statt eigener Endpoint | Ein Call, ein Transport, kein zweiter API-Pfad; Grouping ist integraler Bestandteil der Datenabfrage | +| 2026-04-29 | `groupTree` in `PaginatedResponse` | Immer synchron mit aktuellen Items; kein separater Lade-Call nötig | +| 2026-04-29 | Shared Helper in `routeHelpers.py`, 2 Zeilen pro Route | DRY; gesamte Komplexität an einem Ort; pro Route null Eigenlogik | +| 2026-04-29 | Optimistic UI + debounced Save via normalen refetch | UX sofort; kein Flackern; Persistenz ohne Extra-Call | +| 2026-04-29 | Modelle in `datamodelPagination.py` | `PaginatedResponse` liegt dort; keine neue Datei; Import-Graph bleibt minimal | +| 2026-04-29 | `applyGroupScopeFilter` auch für `mode=ids` und `mode=filterValues` | Filter-Dropdown und Bulk-Select funktionieren korrekt im Gruppen-Scope ohne Sonderbehandlung | +| 2026-04-29 | Gruppen-State nur in `FormGeneratorTable`, nicht im Feature-Hook | Keine Änderungen an bestehenden Hooks nötig; Grouping ist transparent für Seiten-Code | + +--- + +## Umsetzungs-Checkliste + +### Phase 1: Backend Core + +- [ ] `datamodelPagination.py`: `TableGroupNode`, `TableGrouping` Klassen hinzufügen +- [ ] `datamodelPagination.py`: `PaginationParams` um `groupId` + `saveGroupTree` erweitern +- [ ] `datamodelPagination.py`: `PaginatedResponse` um `groupTree` erweitern +- [ ] `datamodelPagination.py`: `normalize_pagination_dict` so erweitern, dass `saveGroupTree` und `groupId` korrekt geparst werden +- [ ] `interfaceDbApp.py`: `getTableGrouping(contextKey)` + `upsertTableGrouping(contextKey, rootGroups)` zu `AppObjects` hinzufügen +- [ ] `routeHelpers.py`: `GroupingContext` Dataclass + `handleGroupingInRequest()` + `applyGroupScopeFilter()` implementieren + +### Phase 2: Route-Erweiterungen + +Pro Route: `handleGroupingInRequest` am Anfang + `applyGroupScopeFilter` vor Pagination + `groupTree` in Response + +- [ ] `routeDataConnections.py` (inkl. `mode=ids`, `mode=filterValues`) +- [ ] `routeDataPrompts.py` +- [ ] `routeDataUsers.py` +- [ ] `routeDataMandates.py` +- [ ] `routeDataFiles.py` +- [ ] Weitere nach Bedarf (Trustee, RealEstate, Invitations, …) + +### Phase 3: Frontend Typen + FormGeneratorTable Grundgerüst + +- [ ] TypeScript-Typen `TableGroupNode` direkt in `FormGeneratorTable.tsx` oder `src/types/tableGrouping.ts` +- [ ] `groupingConfig`-Prop zu `FormGeneratorTableProps` hinzufügen +- [ ] `groupTree` aus `hookData`-Response parsen und in internem State halten +- [ ] `activeGroupId`-State + `_enterGroup` / `_exitGroup` +- [ ] `pendingGroupTree`-State + `_mutateGroupTree` + debounced Save via `hookData.refetch()` +- [ ] `PaginatedResponse` Frontend-Typ um `groupTree` erweitern + +### Phase 4: Render-Logik + +- [ ] `GroupRow`-Komponente: `src/components/FormGenerator/GroupingManager/GroupRow.tsx` +- [ ] Render-Algorithmus: Root-View mit Gruppen-Header-Zeilen und DataRows; "Nicht zugeordnet"-Sektion +- [ ] Gruppen-Scope-View: Breadcrumb, "Zurück"-Button, normales Table-Layout +- [ ] Gedimmte Gruppen ohne sichtbare Items (Root-View) mit "Gruppe öffnen"-Button +- [ ] Expand/Collapse je Gruppe (lokal; isExpanded kommt aus `groupTree`) + +### Phase 5: Aktionen und Interaktion + +- [ ] `actionButtons` + `customActions` auf `GroupRow`-Ebene (Batch auf alle Items via `mode=ids` im Scope) +- [ ] Delete-Gruppe mit `useConfirm`: "Nur Gruppe" vs. "Gruppe + alle Items löschen" +- [ ] "In Gruppe verschieben" als BatchAction bei Multi-Select +- [ ] Kontextmenü-Button je DataRow → Gruppen-Dropdown +- [ ] Drag-and-Drop DataRow → GroupRow + +### Phase 6: GroupingManager Panel + +- [ ] `GroupingManager`-Komponente: Gruppen-Baum-Panel +- [ ] Button "Gruppen" in `FormGeneratorControls` (nur wenn `groupingConfig.enabled`) +- [ ] Neue Gruppe erstellen (`usePrompt`) +- [ ] Gruppe umbenennen (inline) +- [ ] Subgruppe erstellen +- [ ] Gruppe löschen (`useConfirm`) +- [ ] Reihenfolge Up/Down + +### Abschluss + +- [ ] i18n: alle neuen UI-Texte mit `t('...')` getaggt +- [ ] CSS Modules für alle neuen Komponenten +- [ ] `b-reference/frontend-nyla/formgenerator.md` aktualisieren +- [ ] `b-reference/gateway/architecture.md` — `PaginationParams`/`PaginatedResponse`-Erweiterung dokumentieren + +--- + +## Akzeptanzkriterien + +| # | Kriterium (Given-When-Then) | Prio | +|---|---------------------------|------| +| 1 | Given `FormGeneratorTable` mit `groupingConfig.enabled` — When Seite lädt — Then kommt `groupTree` in der normalen List-Response mit; **kein zweiter API-Call** | must | +| 2 | Given User erstellt Gruppe — When 500ms nach letzter Änderung — Then ein einziger `refetch` mit `saveGroupTree` wird abgesendet; nach Reload ist Gruppe vorhanden | must | +| 3 | Given User klickt Gruppe "Kunden" — When `_enterGroup` aufgerufen — Then refetch mit `groupId`; Backend filtert; Pagination und Totalcount beziehen sich auf die Gruppe | must | +| 4 | Given aktiver Gruppen-Scope — When User sucht "Test" — Then `groupId` + Search in einem Call; Backend zeigt nur Treffer in der Gruppe | must | +| 5 | Given aktiver Gruppen-Scope — When User klickt "Alle auswählen" (mode=ids) — Then IDs kommen nur aus der Gruppe, nicht aus der Gesamtliste | must | +| 6 | Given Filter-Dropdown geöffnet im Gruppen-Scope — When Werte geladen — Then kommen nur aus Items der Gruppe (mode=filterValues korrekt) | should | +| 7 | Given `FormGeneratorTable` ohne `groupingConfig` — Then identisches Verhalten wie vor dem Feature | must | +| 8 | Given Gruppe mit 5 Items — When User Delete auf Group-Header — Then Confirm → alle 5 Items gelöscht; Gruppe aus Baum entfernt; ein `refetch` mit aktuellem `saveGroupTree` | must | +| 9 | Given User zieht DataRow auf Gruppen-Header — Then Item wird zur Gruppe zugeordnet; `_mutateGroupTree` + debounced Save | should | + +--- + +## Testplan + +| ID | AC | Art | Automatisiert | Repo-Pfad | Status | +|----|----|----|--------------|-----------|--------| +| T1 | 1, 2 | api | ja | `gateway/tests/test_grouping_helpers.py` | pending | +| T2 | 3, 4, 5, 6 | api | ja | `gateway/tests/test_grouping_helpers.py` | pending | +| T3 | 7 | component | nein | manuell | pending | +| T4 | 8 | api + component | nein | manuell | pending | +| T5 | 9 | component | nein | manuell | pending | + +--- + +## Offene Fragen + +1. **`scope: 'user' | 'mandate'`** im `TableGrouping`-Modell bereits vorbereiten für späteres mandate-weites Sharing? +2. **CSV-Export** soll Gruppen-Spalte enthalten? +3. **`activeGroupId` im URL-State** (`?group=g1`) für Deep-Links? +4. **Max-Tiefe** konfigurierbar (`groupingConfig.maxDepth`) oder feste Warnung nach 3 Ebenen? + +--- + +## Links + +- PR: — +- Referenz FormGenerator: `b-reference/frontend-nyla/formgenerator.md` +- Referenz Gateway-Architektur: `b-reference/gateway/architecture.md` +- Referenz DB-Architektur: `b-reference/platform/database-architecture.md` + +--- + +## Abschluss + +- [ ] `b-reference/frontend-nyla/formgenerator.md` — Grouping-Sektion +- [ ] `b-reference/gateway/architecture.md` — `PaginationParams`/`PaginatedResponse` Erweiterung +- [ ] TOPICS.md geprüft +- [ ] Dieses Dokument → `z-archive/` verschoben diff --git a/c-work/4-done/2026-04-id-unified-knowledge-indexing-rag-concept.md b/c-work/4-done/2026-04-id-unified-knowledge-indexing-rag-concept.md index 138c88f..ecfea24 100644 --- a/c-work/4-done/2026-04-id-unified-knowledge-indexing-rag-concept.md +++ b/c-work/4-done/2026-04-id-unified-knowledge-indexing-rag-concept.md @@ -1,6 +1,6 @@ - + # Unified Knowledge Indexing — One RAG Corpus for All Platform Information @@ -15,7 +15,7 @@ | **Teil 3** | **Feature injection** split into **retrieval** (agent + `buildAgentContext`) vs **corpus** (`indexFile`); **matrix** per `modules/features/*` product; real **gaps** vs false “non-injection”. | | **Implementation phases · Ziele · AC · Testplan** | Rollout, explicit non-goals, acceptance criteria, verification. | -**Single sentence summary:** Keep **retrieval** on **`AgentService`**; unify **when and how** the shared **`interfaceDbKnowledge`** corpus is **filled** (routes, **user connections** / integrations, features, snapshots) behind one **ingestion contract**, without assuming every product uses the workspace agent. +**Single sentence summary:** Keep **retrieval** on **`AgentService`**; unify **when and how** the shared **`interfaceDbKnowledge`** corpus is **filled** (routes, **user connections**, **feature commit points**) behind one **ingestion contract**. **Current roadmap scope:** user-connection lifecycle (**P1a/P1b**), **daily refresh** to close the post-connect delta gap (**P1c**), **explicit user consent + per-connection ingestion preferences** (incl. optional **neutralization**) in **frontend + API**, then **scalable event bus** (**P3**). **Out of current roadmap:** standalone **profile/mandate snapshot** ingestion (former roadmap **P2** — content remains in Teil 2.3 as future option only). ## Beschreibung und Kontext @@ -150,7 +150,7 @@ The first end-to-end AC4 test on a 500-page PDF revealed **three** independent b 2. **Pre-upserts must preserve `_ingestion` metadata and the `indexed` status.** `routeDataFiles._autoIndexFile` persisted a fresh `FileContentIndex` from the pre-scan **before** calling `requestIngestion`, overwriting `structure._ingestion.hash` and `status="indexed"` from any prior successful run. The duplicate check saw a row with empty metadata and re-ran the whole embedding stage. **Rule:** any upsert on the idempotency row taken outside `requestIngestion` MUST read the existing row first and merge forward both `_ingestion` and (where applicable) the terminal `indexed` status. 3. **Extraction-pipeline defaults must preserve granularity for RAG.** `ExtractionOptions.mergeStrategy` defaulted to concatenating every text `ContentPart` into one blob, collapsing a 500-page PDF into a single chunk whose embedding is a blurred average of the whole document — unusable for targeted retrieval. **Rule:** every ingestion lane passes `mergeStrategy=None` explicitly until the default itself can be safely flipped after auditing non-RAG callers. (Tests: `tests/unit/services/test_extraction_merge_strategy.py`.) -**Deferred to P1** (uncovered during P0, not blocking AC1–AC5): +**Deferred (ingestion idempotency hardening)** (uncovered during P0, not blocking AC1–AC5; naming here is **not** the same milestone as **P1 user-connection hooks** below): - **In-flight duplicate detection.** The current duplicate check only matches when `status == "indexed"`, so two nearly-simultaneous calls for the same `sourceId` both run full embedding. Fix candidates: accept `status ∈ {"extracted", "embedding", "indexed"}` with matching hash as "already in progress", or a per-`sourceId` `asyncio.Lock` in `KnowledgeService`. - **Pre-extraction byte-hash shortcut.** `requestIngestion`'s duplicate check runs **after** extraction, so re-indexing a 1.6 MB PDF still spends ~15 s in `runExtraction` before the content hash is computed. The file-bytes SHA already exists in `interfaceDbManagement` for upload-dedup — a short-circuit in `_autoIndexFile` (and symmetric paths) could skip extraction entirely for an unchanged file. @@ -231,12 +231,14 @@ The first end-to-end AC4 test on a 500-page PDF revealed **three** independent b **Email and messaging (Outlook + Gmail via Microsoft / Google user connections) — shared cautions** -- Default tiers: **metadata only** → **snippet** → **full body** → **attachments** (most expensive / sensitive). +- Default tiers: **metadata only** → **snippet** → **full body** → **attachments** (most expensive / sensitive). **Product default** vs **user override** is defined in **§2.6** (per-connection mail depth + attachments). - Apply **quoted-thread stripping**, **signature removal**, and **max body length** before embed. - **Legal hold / retention:** ingestion must respect mandate **delete** and **export** rules; **disconnecting** or **revoking** the mail **connection** must **purge** mail-sourced chunks. ### 2.3 “Account and stuff” — what to index vs. what never to index +**Roadmap note:** Standalone **profile/mandate snapshot** ingestion (formerly roadmap **P2**) is **out of current scope**; the table below remains the **target model** when that work is picked up again. + **Goal:** Give agents **useful, permission-safe** context (“who is this user in this mandate”, “which features are on”, “preferred language”) without creating a **second copy of sensitive credentials** in the vector store. | Data | Typical treatment | @@ -256,6 +258,60 @@ Snapshots should be stored with the same **scope model** as file chunks (`person **Storage (already implemented — not redesigned here):** The platform already uses **one** knowledge persistence stack: **`FileContentIndex`** (incl. `mandateId`, `scope`, status) and **`ContentChunk`** (pgvector embeddings, `fileId`, `userId`, `featureInstanceId`, `contextRef`, optional **`chunkMetadata`**), accessed via **`interfaceDbKnowledge`**. Chunks are **file-anchored** today; **connection- / source-specific** provenance (e.g. `connectionId`, external ids) can ride in **`contextRef` / `chunkMetadata`** until optional schema extensions are justified. **This document targets ingestion triggers and lifecycles**, not a second corpus or a duplicate storage model. +### 2.5 Lifecycle gap and daily refresh (roadmap **P1c**, v1) + +**Gap:** After a successful connect, **bootstrap** runs once (initial fill). **New** mail, files, or tasks that arrive **after** that run are **not** indexed automatically until a **delta** path exists (webhook, `historyId` / `changes` cursors, etc. — see Teil **2.1** row *“Sync for an existing connection”*). + +**Pragmatic mitigation (deliberately simple):** A **daily scheduler** (e.g. once per night, staggered by tenant/load) re-invokes the same **bootstrap walkers** for every **active** `UserConnection` that has **knowledge ingestion enabled** (see **§2.6**). Idempotency + fast-path skips unchanged items; **new** and **changed** items are picked up. + +- **Pros:** No new external dependencies (Pub/Sub, watch renewal) in v1; fits existing BackgroundJob + cron/feature-flag patterns. +- **Con:** Data can lag up to **~24 h** before it appears in RAG — acceptable for v1 product choice. +- **Later (without replacing P1c):** Add per-authority **delta APIs** (Gmail `users.history.list`, Drive `changes.list`, ClickUp tighter polling) to reduce latency and API cost. + +### 2.6 User consent, frontend flow, and per-connection preferences (incl. neutralization) + +**Goal:** The user **explicitly** chooses whether this connection may feed the **shared knowledge store** used for AI/RAG — and **how much**. Without consent, **no** knowledge bootstrap is started for that connection (OAuth may still unlock other product features; that split must be obvious in the UI). + +**Frontend (`frontend_nyla`):** extend the **add connection** flow (and later **connection settings**) with the dialog and controls below; persist choices via Gateway API **before** or **when** triggering knowledge ingestion. + +#### UX when adding a connection + +1. User starts OAuth as today. +2. **Before** or **immediately after** successful authorization: a **dialog** that clearly separates “establish connection” from “add to knowledge base”. +3. **No:** Connection remains usable for other features; either skip `KnowledgeIngestionConsumer.onConnectionEstablished` for the knowledge lane or persist `knowledgeIngestionEnabled=false` and never schedule walkers. +4. **Yes:** Show **advanced settings** (second step or accordion) per **settings catalog** below; persist **per `connectionId`** (or a dedicated preferences row); only then enqueue **bootstrap** (and later **P1c** refresh) with allowed surfaces and tiers. + +**Suggested copy (DE — pick one tone / A-B test):** + +- **Formal:** „Möchten Sie Inhalte aus dieser Verbindung in Ihre **Wissensdatenbank** übernehmen? KI-Funktionen können dann passender auf **Ihre** Dokumente und Nachrichten Bezug nehmen — **nur** mit Ihrer ausdrücklichen Zustimmung und in dem Umfang, den Sie festlegen.“ +- **Approachable:** „Sollen wir aus dieser Verbindung ausgewählte Inhalte sicher in Ihre **persönliche Wissensdatenbank** legen, damit die KI für Sie **besser helfen** kann? Sie entscheiden **was** und **wie stark anonymisiert** — und können das jederzeit in den Einstellungen ändern oder die Daten entfernen.“ + +Mirror in EN if the UI is bilingual. + +#### Minimum settings catalog (all **per connection** where technically applicable) + +| Layer | Setting | Meaning | +|--------|-----------|---------| +| **Master** | **Knowledge ingestion for this connection** | `off` / `on`: gates bootstrap + **§2.5** (P1c) refresh for the knowledge store. | +| **Protection** | **Neutralize / anonymize before embedding** | When `on`: apply the same (or stricter) **neutralization** pipeline as for uploads (`FileItem.neutralize` / platform rules) to connector-sourced text **before** chunking — names, e-mail addresses, phone-like patterns, IBAN-like patterns, per policy. User-facing label **„anonymisiert“** maps to this pipeline (not a cryptographic guarantee). | +| **Mail** (Outlook / Gmail) | **Content depth** | At least: **metadata only** (subject, participants, dates — no body) / **snippet** / **full cleaned body** (after `cleanEmailBody` and caps). | +| **Mail** | **Index attachments** | `off` / `on` (with size/type caps). | +| **Files** (Drive / SharePoint / OneDrive) | **Index binary files** | `off` / `on`; optional **MIME allowlist** (Office/PDF/text only) as a simplified UX preset. | +| **ClickUp** | **Scope** | `titles only` / `title + description` / `+ comments` / optional `attachments`. | +| **Microsoft** | **Parity** | Same dimensions where Graph surfaces mirror Google (mailbox / drive-like). | +| **General** | **Time window** | “Only index items from the last **N** days” (aligns with existing walker caps; slider with a sensible max). | +| **General** | **Help: what RAG is not** | Short explainer: not real-time mail; delay until next scheduled run (**§2.5**). | + +**Optional power-user toggles (same screen, collapsed):** per authority **which surfaces** ingest (e.g. **Google:** Gmail on/off, Drive on/off; **Microsoft:** SharePoint on/off, Outlook on/off — when product exposes both). Reduces accidental over-breadth without extra wizard steps. + +**Backend consequence:** Walkers read persisted preferences for `connectionId` each run and **filter** surfaces and payload tiers **before** `indexFile`. On preference change, product decision: trigger **re-sync**, or apply only to **new** items — document the chosen rule. + +#### Neutralization when the user opts in + +- **Ingestion on** + **neutralization on:** After content is obtained (virtual text or extraction output), apply the **neutralization stage** **before** chunking/embedding; **that** text is what gets embedded. +- **Neutralization off:** Still apply baseline **hygiene** where already defined (e.g. `cleanEmailBody` for quotes/signatures) — hygiene **≠** full PII removal. +- **Compliance copy:** If the user chooses **full body**, state clearly that **perfect** anonymization is not guaranteed without neutralization. + --- ## Teil 3 — Feature injection: retrieval vs corpus, agent loop, and real gaps @@ -338,11 +394,11 @@ Then add **`requestIngestion` / `indexFile`** at the **feature commit point** (o 3. **Unified façade** — one ingestion API; avoid a second embedding pipeline. 4. **Purge** — tie to **`fileId`**, business key, or future connector purge keys on revoke/delete. -### 3.7 Phasing +### 3.7 Phasing (feature matrix — **not** the same numbering as roadmap **P1c/P1d/P3** above) -- **P0:** For **each** row in §3.3, confirm **retrieval** vs **corpus** paths; document “satisfied by agent+upload+tools” vs “needs feature hook.” -- **P1:** Implement **feature-native corpus** for one domain with a clear §3.5 gap (e.g. **trustee** entity text, **teamsbot** persisted transcript). -- **P2:** **Chatbot** architecture decision: integrate **`serviceKnowledge`** or keep parallel retrieval; if integrate, add explicit **corpus** rules for config/FAQ. +- **FM0:** For **each** row in §3.3, confirm **retrieval** vs **corpus** paths; document “satisfied by agent+upload+tools” vs “needs feature hook.” +- **FM1:** Implement **feature-native corpus** for one domain with a clear §3.5 gap (e.g. **trustee** entity text, **teamsbot** persisted transcript). +- **FM2:** **Chatbot** architecture decision: integrate **`serviceKnowledge`** or keep parallel retrieval; if integrate, add explicit **corpus** rules for config/FAQ. --- @@ -350,12 +406,41 @@ Then add **`requestIngestion` / `indexFile`** at the **feature commit point** (o Phases align with **Teil 1** (façade), **Teil 2** (connector + trigger catalog), and **Teil 3.7** (feature matrix and feature-native corpus pilots). **P0** overlaps **Teil 3.7 P0** (complete the per-feature matrix before large builds). +**Authority rollout (2026-04-24):** The **user-connection ingestion lane** (bootstrap + purge tied to **`UserConnection`**) is delivered **per OAuth authority**: **`msft` (P1a)**, **`google`** + **`clickup` (P1b)** — same consumer, dispatcher fan-out, purge-by-`connectionId`, and unit tests for walkers + consumer. **Next product slices:** **P1c** (daily refresh, **§2.5**), **consent + per-connection preferences + frontend** (**§2.6**), then **P3** (event bus at scale). + | Phase | Outcome | |-------|---------| | **P0 — Façade + idempotency** *(done, 2026-04-21)* | Single `requestIngestion` / `getIngestionStatus` entry point on `KnowledgeService` with content-hash idempotency, provenance in `structure._ingestion`, and structured logging (`ingestion.queued` / `ingestion.indexed` / `ingestion.skipped.duplicate` / `ingestion.failed`). All prior `indexFile` call sites now route through the façade: `routeDataFiles._autoIndexFile`, `commcoach/serviceCommcoachIndexer.indexSessionData`, `serviceAgent/coreTools/_workspaceTools.readFile`, `serviceAgent/coreTools/_documentTools.describeImage`. Agent tools no longer carry on-demand extraction + ingestion fallbacks — they are pure consumers of the knowledge store. **Teil 3.3** matrix audited. Three implementation bugs fixed during verification: stable content hash, pre-upsert `_ingestion` preservation, `mergeStrategy=None` for per-page granularity (see **§1.4 Implementation pitfalls**). | -| **P1 — User-connection hooks** *(done, 2026-04-21)* | `connection.established` / `connection.revoked` callbacks emitted from every OAuth callback (`routeSecurityMsft`, `routeSecurityGoogle`, `routeSecurityClickup`) and from `routeDataConnections.disconnect_service` / `delete_connection`; the `ConnectionStatus.INACTIVE` enum bug (the value did not exist) was fixed by switching the disconnect path to `ConnectionStatus.REVOKED`. A new central `KnowledgeIngestionConsumer` (`subConnectorIngestConsumer.py`, registered in `app.py` lifespan) maps `established` to a `connection.bootstrap` BackgroundJob and `revoked` to a synchronous purge through `KnowledgeService.purgeConnection` → `interfaceDbKnowledge.deleteFileContentIndexByConnectionId`. `FileContentIndex` gained `connectionId` and `sourceKind` columns (auto-applied by `connectorDbPostgre`); `IngestionJob` carries both end-to-end so every chunk is purgeable by connection. **All three OAuth authorities are wired up** with one bootstrap module per service: `subConnectorSyncSharepoint.py` (`sourceKind="sharepoint_item"`, `eTag` as `contentVersion`, walks sites with the `@odata.nextLink` paginated `SharepointAdapter.browse`), `subConnectorSyncOutlook.py` (virtual `outlook_message` documents — header / snippet / cleaned body via the shared `cleanEmailBody` utility — with `changeKey` revisions and optional `outlook_attachment` child jobs), `subConnectorSyncGdrive.py` (`gdrive_item`, `modifiedTime` revisions, recursive walk from My Drive root with depth/age caps and Google-Doc export support inherited from `DriveAdapter.download`), `subConnectorSyncGmail.py` (virtual `gmail_message` documents with `historyId` revisions, walks `INBOX + SENT` by default, MIME-tree body extraction prefers `text/plain` and falls back to `text/html`, optional `gmail_attachment` child jobs), `subConnectorSyncClickup.py` (virtual `clickup_task` documents with `date_updated` revisions, walks teams → spaces → folder/folderless lists → tasks with workspace and per-workspace list caps, header carries name/status/list/space/assignees/tags/url so search prompts retrieve task context without a live API call). The dispatcher `_bootstrapJobHandler` fans out per authority (msft → sharepoint+outlook in parallel, google → drive+gmail in parallel, clickup → tasks); unsupported authorities log `ingestion.connection.bootstrap.skipped reason=unsupported_authority`. Structured-log schema (started / progress / done / purged) defined in **§ Structured ingestion logs** below. Eight new unit tests (purge, consumer dispatch + per-authority routing, `cleanEmailBody`, bootstrapSharepoint, bootstrapOutlook, bootstrapGmail, bootstrapGdrive, bootstrapClickup) lock the contract. **Retrieval threshold calibration (2026-04-21):** during UI verification `buildAgentContext` returned `instanceChunks=0` despite 640 correctly-indexed rows — root cause was overly aggressive `minScore` thresholds (Layer 1 `0.65`, Layer 1.5 `0.55`, Layer 3 `0.70`) versus realistic `text-embedding-3-small` cosine similarities in the `0.30`–`0.55` range. All three thresholds lowered to `0.35`; agent then correctly synthesized answers from indexed Outlook/SharePoint content without resorting to live tools. | -| **P2 — Profile & mandate snapshots** | Allowlisted fields only (**Teil 2.3**); regenerate on events; explicit admin toggle per mandate if needed. | -| **P3 — Event bus** | Move direct calls to async consumer where load requires it (**Teil 2.4** scalable target). | +| **P1a — User-connection hooks (Microsoft `msft`)** *(done, 2026-04-21)* | **`connection.established`** / **`connection.revoked`** emitted from **Microsoft** data-OAuth success paths and from **disconnect/delete** when the row is **`msft`** (incl. **`ConnectionStatus.REVOKED`** fix where **`INACTIVE`** was invalid). Central **`KnowledgeIngestionConsumer`** (`subConnectorIngestConsumer.py`, **`app.py`** lifespan) maps **`established`** → **`connection.bootstrap`** BackgroundJob and **`revoked`** → synchronous **`KnowledgeService.purgeConnection`** → **`interfaceDbKnowledge.deleteFileContentIndexByConnectionId`**. **`FileContentIndex.connectionId`** + **`sourceKind`** (and **`IngestionJob`** carrying both) make connector-sourced rows purgeable. **Bootstrap modules live for Microsoft:** **`subConnectorSyncSharepoint.py`** (`sourceKind="sharepoint_item"`, **`eTag`** as `contentVersion`, **`SharepointAdapter.browse`** with **`@odata.nextLink`** pagination) and **`subConnectorSyncOutlook.py`** (virtual **`outlook_message`** docs — header / snippet / cleaned body via **`cleanEmailBody`**, **`changeKey`** revisions, optional **`outlook_attachment`** child jobs). Dispatcher **`_bootstrapJobHandler`** runs **SharePoint + Outlook in parallel** for **`msft`**. Structured logs: **§ Structured ingestion logs**. **Retrieval threshold calibration (2026-04-21):** **`buildAgentContext`** **`minScore`** layers lowered to **`0.35`** so **`text-embedding-3-small`** matches real cosine scores; validated on **Outlook/SharePoint–indexed** content. **Tests (P1a):** purge, consumer **msft** dispatch, **`cleanEmailBody`**, **`bootstrapSharepoint`**, **`bootstrapOutlook`**. | +| **P1b — User-connection hooks (Google + ClickUp)** *(done, 2026-04)* | Parity with **`msft`**: **`routeSecurityGoogle`** / **`routeSecurityClickup`** call **`KnowledgeIngestionConsumer.onConnectionEstablished`** after token save; **`routeDataConnections`** disconnect/delete call **`onConnectionRevoked`** for **all** authorities. **`_bootstrapJobHandler`** fans out **google → `bootstrapGdrive` + `bootstrapGmail`** in parallel and **clickup → `bootstrapClickup`**. Walkers: `subConnectorSyncGdrive.py`, `subConnectorSyncGmail.py`, `subConnectorSyncClickup.py` + `subTextClean.py`. Unit tests: `test_bootstrap_gdrive.py`, `test_bootstrap_gmail.py`, `test_bootstrap_clickup.py`, extended `test_knowledge_ingest_consumer.py`. | +| **P1c — Connection refresh (lifecycle v1)** *(next)* | **Daily** (or nightly) **scheduled** re-run of the same bootstrap walkers for connections with **knowledge ingestion enabled** (**§2.6**). Reuses idempotency + fast-path; closes the **post-connect delta gap** without webhooks in v1. Observability: same log family as bootstrap; optional `event` suffix or `reason=scheduled_refresh` for shippers. | +| **P1d — Consent + preferences + UI** *(next)* | Persist **§2.6** settings **per `connectionId`**; Gate **`onConnectionEstablished`** / P1c jobs on user choice; **`frontend_nyla`** connection wizard + settings screen; walkers honor mail/file/ClickUp depth and **neutralization** flag. | +| **~~P2 — Profile & mandate snapshots~~** | **Removed from active roadmap** (focus: connections + feature corpus + scale). Target content remains documented in **§2.3** for a future re-entry when needed. | +| **P3 — Event bus** | Move direct calls to async consumer where load requires it (**Teil 2.4** scalable target). Remains in scope. | + +### P1b checklist *(completed — kept for audit trail)* + +1. **`routeSecurityGoogle`:** after successful **data** OAuth, enqueue **same** ingestion consumer path as Microsoft (pass **`connectionId`**, **`AuthAuthority.google`**, mandate/user scope). +2. **`routeSecurityClickup`:** after successful OAuth / token persistence, same. +3. **`routeDataConnections`:** verify **disconnect_service** / **delete_connection** emit **revoke** (or call **`purgeConnection`**) for **google** and **clickup** rows, not only **msft**. +4. **`_bootstrapJobHandler`:** remove any **“unsupported_authority”** skip for **`google`** / **`clickup`** once walkers are registered; keep skip only for **future** authorities. +5. **Quality bar:** T10/T12–T15 in the testplan — extend from **Microsoft-only** assumptions to **all three** **`routeDataConnections`** OAuth authorities. + +### P1c / P1d checklist *(next engineering slices)* + +1. **P1c:** BackgroundJob or cron entry; feature flag; per-tenant stagger; only connections with **knowledge ingestion = on**; metrics on `indexed` vs `skippedDup` per run. +2. **P1d ✅ — implemented:** + - [x] **`UserConnection`** extended with `knowledgeIngestionEnabled: bool` (default `False` = strict opt-in) and `knowledgePreferences: Optional[Dict]` (`schemaVersion=1`); DB auto-migration adds columns on startup. + - [x] **`routeDataConnections` `create_connection`** accepts `knowledgeIngestionEnabled` + `knowledgePreferences` in request body and persists them before returning. + - [x] **OAuth callbacks** (`routeSecurityGoogle`, `routeSecurityMsft`, `routeSecurityClickup`) gate `callbackRegistry.trigger("connection.established", …)` on `connection.knowledgeIngestionEnabled`; emit structured log `ingestion.connection.bootstrap.skipped reason=consent_disabled` when disabled. + - [x] **`_bootstrapJobHandler`** defensive re-check: loads connection via `getUserConnectionById` and no-ops if flag was disabled after OAuth (race protection). + - [x] **`IngestionJob.neutralize: bool`** added; `requestIngestion` + `_indexFileInternal` thread it through; for `sourceKind != "file"` the flag drives `_shouldNeutralize` directly; for `sourceKind == "file"` the `FileItem.neutralize` column remains authoritative. + - [x] **`subConnectorPrefs.py`** — `loadConnectionPrefs(connectionId)` helper + `ConnectionIngestionPrefs` dataclass with safe defaults for all §2.6 keys. + - [x] **All five walkers** (Gmail, GDrive, ClickUp, Outlook, SharePoint) load prefs at bootstrap start; limits structs gain `mailContentDepth` + `neutralize` (mail walkers), `filesIndexBinaries` (Drive), `clickupScope` (ClickUp), and `neutralize` (all). + - [x] **Unit tests** (`test_p1d_consent_prefs.py` — 10 tests): consent gate no-op, prefs defaults + full mapping, Gmail depth modes (metadata/snippet/full), ClickUp scope (titles vs description). + - [x] **Frontend** (`frontend_nyla`): `AddConnectionWizard` 4-step modal (connector → consent → preferences → summary + OAuth); old three-button row replaced with single „Verbindung hinzufügen“ button; `createConnectionAndAuth` hook method; `KnowledgePreferences` type in `connectionApi.ts`. + + **Default policy (document for deploy):** `knowledgeIngestionEnabled` defaults to `False` for all new connections. Existing connections (before P1d deploy) have the column `NULL`/`False` — **no bootstrap is triggered retroactively**. Users must explicitly opt in via the wizard or connection settings. If the team decides to migrate existing connections to `True`, a one-time migration script must be run and communicated via release note. --- @@ -366,7 +451,8 @@ Phases align with **Teil 1** (façade), **Teil 2** (connector + trigger catalog) - One **ingestion contract** for all features and connector lifecycles. - Indexing **decoupled** from the agent loop (agents may still *invoke* tools that ultimately call ingestion, but ingestion must not *depend* on an agent run). - **Explicit** handling of connection establishment, sync, and revocation. -- **Bounded** indexing of user/mandate context with a clear PII policy. +- **Bounded** indexing of user/mandate context with a clear PII policy. +- **Explicit user consent** and **per-connection** ingestion preferences (incl. optional **neutralization**) before connector content enters the knowledge store (**§2.6**). **Explizit NICHT:** @@ -379,7 +465,8 @@ Phases align with **Teil 1** (façade), **Teil 2** (connector + trigger catalog) ## Betroffene Module (erwartet) - **Gateway:** `serviceKnowledge`, file upload routes, connector OAuth handlers, sync workers, possibly new `serviceKnowledgeIngest` or package under `modules/serviceCenter/services/`. -- **Interfaces:** `interfaceDbKnowledge` extensions for source metadata if needed. +- **Interfaces:** `interfaceDbKnowledge` extensions for source metadata if needed; **`interfaceDbApp`** (or adjacent) for **per-`connectionId`** ingestion preferences from **§2.6**. +- **Frontend:** `frontend_nyla` — connection wizard + connection detail settings (consent, depth toggles, neutralization, time window). - **Wiki / Reference:** `b-reference/gateway/ai-agent.md` (ingestion vs. retrieval) after implementation. --- @@ -388,19 +475,19 @@ Phases align with **Teil 1** (façade), **Teil 2** (connector + trigger catalog) | Thema | Optionen | |-------|----------| -| **Email bodies** | Full text vs. summary-only vs. attachment-only | +| **Email bodies** | Default product stance is **user-configurable per connection** (**§2.6** table: metadata / snippet / full cleaned body); mandate policy may still cap max tier. | | **Multi-tenant isolation audits** | Periodic job to verify chunk `mandateId` matches connection | | **Cost caps** | Per-mandate embedding budget; defer large backfills | -| **Neutralization** | Mandatory for certain `sourceKind`s even when not file-upload | +| **Neutralization** | **User opt-in** per connection (**§2.6**); optional **mandate floor** (“never below snippet+neutralize for mail”) remains a separate governance decision. | | **Provenance shape** | First-class DB columns vs **documented `chunkMetadata` keys** for `connectionId`, external id, revision (must support **Teil 2** purge rules). | -| **In-flight duplicate handling** | Accept `status ∈ {"extracted","embedding","indexed"}` with matching hash as in-progress (cheap, lossy under failure) **vs** per-`sourceId` `asyncio.Lock` in `KnowledgeService` (strict, requires singleton) — see **§1.4 Deferred to P1**. | -| **Pre-extraction dedup shortcut** | Short-circuit `_autoIndexFile` via the file-bytes SHA in `interfaceDbManagement` before running `runExtraction` (~15 s saved per re-index of a large PDF) — see **§1.4 Deferred to P1**. | +| **In-flight duplicate handling** | Accept `status ∈ {"extracted","embedding","indexed"}` with matching hash as in-progress (cheap, lossy under failure) **vs** per-`sourceId` `asyncio.Lock` in `KnowledgeService` (strict, requires singleton) — see **§1.4 Deferred (ingestion idempotency hardening)**. | +| **Pre-extraction dedup shortcut** | Short-circuit `_autoIndexFile` via the file-bytes SHA in `interfaceDbManagement` before running `runExtraction` (~15 s saved per re-index of a large PDF) — see **§1.4 Deferred (ingestion idempotency hardening)**. | --- ## Structured ingestion logs (P1 schema) -The connection-lifecycle lane emits the following structured log events. Each event is a single `logger.info` / `.warning` / `.error` call with a stable `extra={"event": ...}` field so downstream log shippers can route on `event` without parsing the message string. +The connection-lifecycle lane emits the following structured log events. **`part`** values **`sharepoint`**, **`outlook`**, **`gdrive`**, **`gmail`**, and **`clickup`** are all **implemented** for bootstrap; **P1c** may add the same events with a distinguishable `reason` / `jobType` for **scheduled refresh** (exact field TBD in implementation). Each event is a single `logger.info` / `.warning` / `.error` call with a stable `extra={"event": ...}` field so downstream log shippers can route on `event` without parsing the message string. | `event` | Severity | Emitter | Required `extra` keys | Meaning | |---------|----------|---------|------------------------|---------| @@ -409,7 +496,7 @@ The connection-lifecycle lane emits the following structured log events. Each ev | `ingestion.connection.bootstrap.progress` | info | bootstrap walkers | `connectionId`, `part`, `processed`, `skippedDup`, `failed` | Heart-beat every ~50 items so long-running runs are observable. | | `ingestion.connection.bootstrap.done` | info | bootstrap walkers + façade-level totals | `connectionId`, `part`, `indexed`, `skippedDup`, `skippedPolicy`, `failed`, `durationMs` (Outlook/Gmail add `attachmentsIndexed`; SharePoint/Drive add `bytes`; ClickUp adds `workspaces` + `lists`) | Walker finished cleanly. | | `ingestion.connection.bootstrap.failed` | error | `_bootstrapJobHandler` | `part`, `connectionId`, `error` | One bootstrap part raised — recorded but the other parts still complete. | -| `ingestion.connection.bootstrap.skipped` | info | `_bootstrapJobHandler` | `connectionId`, `authority`, `reason` (`unsupported_authority`) | Authority has no bootstrap module registered (e.g. a future provider). | +| `ingestion.connection.bootstrap.skipped` | info | `_bootstrapJobHandler` + OAuth callbacks + defensive check in `_bootstrapJobHandler` | `connectionId`, `authority`, `reason` (`unsupported_authority` │ `consent_disabled`) | Authority has no bootstrap module registered (e.g. a future provider) — **or** user has not consented (`knowledgeIngestionEnabled=False`). | | `ingestion.connection.purged` | info | `_onConnectionRevoked` | `connectionId`, `authority`, `reason`, `indexRows`, `chunks` | Knowledge purge for a revoked connection completed; numbers reflect the deleted rows. | | `ingestion.connection.purged.failed` | error | `_onConnectionRevoked` | `connectionId`, `error` | Purge raised; the revoke event was still acknowledged upstream. | @@ -421,16 +508,17 @@ All events should keep field naming consistent with the existing `ingestion.queu - **Gateway reference (retrieval + knowledge):** `wiki/b-reference/gateway/architecture.md`, `wiki/b-reference/gateway/ai-agent.md` - **Implementation touchpoints (indicative):** `gateway/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py`, `gateway/modules/routes/routeDataFiles.py`, `gateway/modules/features/commcoach/serviceCommcoachIndexer.py`, agent `coreTools` `_documentTools` / `_workspaceTools`, `gateway/modules/datamodels/datamodelExtraction.py` (`ExtractionOptions.mergeStrategy: Optional[MergeStrategy]`). - **Unit tests (P0 guardrails):** `gateway/tests/unit/services/test_ingestion_hash_stability.py`, `gateway/tests/unit/services/test_extraction_merge_strategy.py`. -- **Unit tests (P1 guardrails):** `gateway/tests/unit/services/test_connection_purge.py`, `gateway/tests/unit/services/test_knowledge_ingest_consumer.py`, `gateway/tests/unit/services/test_clean_email_body.py`, `gateway/tests/unit/services/test_bootstrap_sharepoint.py`, `gateway/tests/unit/services/test_bootstrap_outlook.py`, `gateway/tests/unit/services/test_bootstrap_gmail.py`, `gateway/tests/unit/services/test_bootstrap_gdrive.py`, `gateway/tests/unit/services/test_bootstrap_clickup.py`. -- **P1 implementation touchpoints:** `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subTextClean.py`, `gateway/modules/interfaces/interfaceDbKnowledge.py` (`deleteFileContentIndexByConnectionId`), `gateway/modules/datamodels/datamodelKnowledge.py` (`FileContentIndex.connectionId` + `sourceKind`), `gateway/modules/connectors/providerMsft/connectorMsft.py` (`@odata.nextLink`-loop in `SharepointAdapter.browse`, `eTag` in `_graphItemToExternalEntry`), `gateway/modules/routes/routeSecurityMsft.py` / `routeSecurityGoogle.py` / `routeSecurityClickup.py` / `routeDataConnections.py` (callback emission + `ConnectionStatus.REVOKED` fix), `gateway/app.py` (consumer registration in lifespan). +- **Unit tests (P1a — Microsoft, done):** `gateway/tests/unit/services/test_connection_purge.py`, `gateway/tests/unit/services/test_knowledge_ingest_consumer.py` (incl. **msft** fan-out), `gateway/tests/unit/services/test_clean_email_body.py`, `gateway/tests/unit/services/test_bootstrap_sharepoint.py`, `gateway/tests/unit/services/test_bootstrap_outlook.py`. +- **Unit tests (P1b — Google + ClickUp, done):** **`test_knowledge_ingest_consumer`** (google / clickup fan-out), **`test_bootstrap_gmail.py`**, **`test_bootstrap_gdrive.py`**, **`test_bootstrap_clickup.py`**. **P1d (done):** **`test_p1d_consent_prefs.py`** (10 tests: consent gate, prefs parsing, Gmail depth modes, ClickUp scope). **P1c:** add scheduler tests when implemented. +- **P1 implementation touchpoints:** `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py`, `gateway/modules/serviceCenter/services/serviceKnowledge/subTextClean.py`, `gateway/modules/interfaces/interfaceDbKnowledge.py` (`deleteFileContentIndexByConnectionId`), `gateway/modules/datamodels/datamodelKnowledge.py` (`FileContentIndex.connectionId` + `sourceKind`), `gateway/modules/connectors/providerMsft/connectorMsft.py` (`@odata.nextLink`-loop in `SharepointAdapter.browse`, `eTag` in `_graphItemToExternalEntry`), `gateway/modules/connectors/providerGoogle/connectorGoogle.py` (P1b: Drive + Gmail revision keys and download/export paths), `gateway/modules/routes/routeSecurityMsft.py` (P1a callbacks), `gateway/modules/routes/routeSecurityGoogle.py` and `gateway/modules/routes/routeSecurityClickup.py` (P1b: parity callbacks), `gateway/modules/routes/routeDataConnections.py` (revoke for **all** authorities), `gateway/app.py` (consumer registration in lifespan). ## Akzeptanzkriterien (Plan-Ebene) | # | Kriterium | Prio | |---|-----------|------| | 1 | Every new **file** that should be searchable triggers ingestion **without** requiring an agent session. | must | -| 2 | **User connection** connect / disconnect has defined ingestion or purge behavior documented and implementable. | must | -| 3 | **Profile/mandate** snapshots use an explicit allowlist; secrets never enter the embedding pipeline. | must | +| 2 | **User connection** connect / disconnect has defined ingestion or purge behavior **for each** OAuth authority **`routeDataConnections`** supports (**P1a** **`msft`**, **P1b** **`google`** / **`clickup`**); **plus** user-controlled **opt-in** and **preference bundle** before ingestion (**P1d**, **§2.6**). | must | +| 3 | **Profile/mandate** snapshot ingestion (**former roadmap P2**) is **deferred**; when re-opened, snapshots must use an explicit allowlist and never embed secrets. Until then, **§2.6** consent + neutralization covers connector-sourced PII risk. | should (reactivated when P2 returns) | | 4 | Ingestion is **idempotent** for unchanged content (no duplicate embedding work). Verified 2026-04-21 on a 500-page PDF: second re-index trigger logs `ingestion.skipped.duplicate` with a stable hash, zero embedding API calls. See **§1.4 pitfalls** for the three bug classes that had to be fixed first. | must | | 5 | **Teil 3.3** matrix completed: every `modules/features/*` product row has **retrieval** (agent vs none), **corpus** (upload / tools / feature indexer), and **gap** explicitly stated—not “non-injecting” if **`AgentService`** already provides retrieval injection. | should | @@ -449,9 +537,10 @@ All events should keep field naming consistent with the existing `ingestion.queu | T7 | Bleiben bei Multi-Page-PDFs die Per-Page-Chunks erhalten (keine `MergeStrategy`-Konkatenation)? | Unit: `tests/unit/services/test_extraction_merge_strategy.py`. Live: 500-Seiten-PDF → 563 ContentObjects, 567 Embedding-Chunks in 24 Batches (verifiziert 2026-04-21). | | T8 | Überleben `_ingestion.hash` und `status="indexed"` einen Pre-Scan-Re-Upsert in `_autoIndexFile`? | Review `routeDataFiles._autoIndexFile` Zeile ~127: existing row wird vor upsert gelesen und `_ingestion` + `indexed` in frischen `contentIndex` gemerged. Live: zweiter Trigger → `ingestion.skipped.duplicate` statt Re-Embedding. | | T9 | Räumt ein `connection.revoked` Event **alle** `FileContentIndex`-Rows + `ContentChunk`s einer Connection und **nichts anderes** auf (Uploads ohne `connectionId`, andere Connections bleiben intakt)? | Unit: `tests/unit/services/test_connection_purge.py` (3 Cases: positive purge, leerer connectionId-Noop, unbekannter connectionId). | -| T10 | Dispatcht der `KnowledgeIngestionConsumer` `connection.established` korrekt als asynchroner `connection.bootstrap` Job (msft → SharePoint + Outlook parallel; google → Drive + Gmail parallel; clickup → Tasks; unbekannte Authorities `skipped.reason="unsupported_authority"`) und `connection.revoked` synchron als Purge? | Unit: `tests/unit/services/test_knowledge_ingest_consumer.py` (8 Cases: established enqueue, missing-id ignore, revoked purge, missing-id ignore, skip-unsupported, msft fan-out, google fan-out, clickup dispatch). | +| T10 | Dispatcht der `KnowledgeIngestionConsumer` `connection.established` korrekt als asynchroner `connection.bootstrap` Job (**P1a:** **msft** → SharePoint + Outlook parallel; **P1b:** **google** → Drive + Gmail parallel; **clickup** → Tasks) und `connection.revoked` synchron als Purge — **für jede** der drei **`routeDataConnections`**-Authorities? | **P1a + P1b (done):** `test_knowledge_ingest_consumer.py` — alle drei Authorities + revoke; unbekannte Authorities `skipped.reason="unsupported_authority"`. **P1d:** zusätzlich nur bei **Consent = ja** dispatch. | | T11 | Reduziert `cleanEmailBody` ein realistisches Outlook-HTML auf den eigenen Body-Anteil (HTML strip, Quote-Strip EN+DE, Signature-Strip, Whitespace-Collapse, `maxChars`-Truncate)? | Unit: `tests/unit/services/test_clean_email_body.py` (8 Cases). Konsequenz: `bootstrapOutlook` schickt nie HTML/Quoted-Replies/Signaturen in den Embedding-Pipeline-Schritt. | | T12 | Sind die Bootstrap-Walker für SharePoint und Outlook idempotent gegen ein zweites Run mit unveränderten `eTag` / `changeKey`? | Unit: `tests/unit/services/test_bootstrap_sharepoint.py` + `tests/unit/services/test_bootstrap_outlook.py`. Mock-Adapter liefern stable revisions; KnowledgeService-Fake meldet `duplicate` und das Result-Objekt bilanziert `skippedDuplicate`. | -| T13 | Walked `bootstrapGmail` `INBOX + SENT`, parsed MIME-Bodies (preferring `text/plain`, falling back to `text/html`), folgt `nextPageToken`-Pagination und ist idempotent gegen identische `historyId` Revisions? | Unit: `tests/unit/services/test_bootstrap_gmail.py` (6 Cases: header/snippet/body content-objects, MIME plain-vs-html preference, HTML fallback, multi-label fan-out, `nextPageToken` pagination, duplicate accounting). | -| T14 | Walked `bootstrapGdrive` My Drive rekursiv (Folder-MIME-Erkennung, `maxDepth`), respektiert den `maxAgeDays`-Recency-Filter und ist idempotent gegen identische `modifiedTime` Revisions? | Unit: `tests/unit/services/test_bootstrap_gdrive.py` (4 Cases: site/subfolder walk, duplicate accounting, recency-skip via `skippedPolicy`, provenance carries `authority="google"` + `service="drive"`). | -| T15 | Walked `bootstrapClickup` Workspaces → Spaces → Folder/Folderless Lists → Tasks unter `maxWorkspaces` / `maxListsPerWorkspace` / `maxTasks` Caps, respektiert den `maxAgeDays`-Recency-Filter und ist idempotent gegen identische `date_updated` Revisions? | Unit: `tests/unit/services/test_bootstrap_clickup.py` (4 Cases: hierarchy walk indexes 4 tasks across 2 lists, duplicate accounting, recency-skip via `skippedPolicy`, `maxTasks` cap). | +| T13 | Walked `bootstrapGmail` `INBOX + SENT`, parsed MIME-Bodies (preferring `text/plain`, falling back to `text/html`), folgt `nextPageToken`-Pagination und ist idempotent gegen identische `historyId` Revisions? | **P1b (done):** Unit `test_bootstrap_gmail.py`. **P1d:** Walker respektiert **Content depth** aus **§2.6** (Metadaten/Snippet/Body). | +| T14 | Walked `bootstrapGdrive` My Drive rekursiv (Folder-MIME-Erkennung, `maxDepth`), respektiert den `maxAgeDays`-Recency-Filter und ist idempotent gegen identische `modifiedTime` Revisions? | **P1b (done):** Unit `test_bootstrap_gdrive.py`. **P1d:** „Binärdateien“ / MIME-Allowlist aus **§2.6**. | +| T15 | Walked `bootstrapClickup` Workspaces → Spaces → Folder/Folderless Lists → Tasks unter `maxWorkspaces` / `maxListsPerWorkspace` / `maxTasks` Caps, respektiert den `maxAgeDays`-Recency-Filter und ist idempotent gegen identische `date_updated` Revisions? | **P1b (done):** Unit `test_bootstrap_clickup.py`. **P1d:** ClickUp-**Scope** (Titel/Beschreibung/Kommentare) aus **§2.6**. | +| T16 | Führt der **P1c**-Tagesjob nur Verbindungen mit **Wissens-Injektion = ein** aus und bleiben Kosten/API-Limits durch Idempotenz + Fast-Path beherrschbar? | Integration oder Unit mit Fake-Clock: zweiter Lauf → überwiegend `skippedDup`; Logs `ingestion.connection.bootstrap.*` mit erkennbarem Scheduled-`reason` (falls implementiert). | diff --git a/d-guides/deployment/poweron-sec.kdbx b/d-guides/deployment/poweron-sec.kdbx index 0daaaed913253099c8f6b11aab18d9ada44c603a..5c52c62d188531fa2f621b728a38d2a0e6bb0c37 100644 GIT binary patch literal 22334 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