diff --git a/aria-brain/backfill_scope.py b/aria-brain/backfill_scope.py new file mode 100644 index 0000000..0db6c3b --- /dev/null +++ b/aria-brain/backfill_scope.py @@ -0,0 +1,90 @@ +"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope` +(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der +Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden. + +Heuristik (datengetrieben aus dem realen Bestand): + - type=preference / fact / conversation / reminder -> personal + - source in (seed, auto-feedback) -> system + - type=identity -> system + - type in (rule, tool, skill) und category in SYSTEM_CATS -> system + - sonst -> personal (sicher: nichts leakt) + +Aufruf im Brain-Container: + docker exec aria-brain python3 /app/backfill_scope.py # dry-run + docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt +""" +import os +import sys +from collections import Counter + +from qdrant_client import QdrantClient +from qdrant_client.http import models as qm + +COLLECTION = "aria_memory" +SYSTEM_CATS = { + "sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten", + "voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit", + "pentest", "ausgabe", +} + + +def compute_scope(pl: dict) -> str: + typ = pl.get("type") + src = pl.get("source") + cat = (pl.get("category") or "").lower() + if typ == "preference": + return "personal" + if typ in ("fact", "conversation", "reminder"): + return "personal" + if src in ("seed", "auto-feedback"): + return "system" + if typ == "identity": + return "system" + if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS: + return "system" + return "personal" + + +def main(): + apply = "--apply" in sys.argv + force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben + c = QdrantClient( + host=os.environ.get("QDRANT_HOST", "aria-qdrant"), + port=int(os.environ.get("QDRANT_PORT", "6333")), + ) + pts, _ = c.scroll(collection_name=COLLECTION, limit=5000, + with_payload=True, with_vectors=False) + + per_scope: dict[str, list] = {"system": [], "personal": []} + pinned_examples = Counter() + skipped = 0 + for p in pts: + pl = p.payload or {} + if pl.get("scope") in ("system", "personal") and not force: + skipped += 1 + continue + scope = compute_scope(pl) + per_scope[scope].append(p.id) + if pl.get("pinned"): + pinned_examples[(scope, pl.get("source"), pl.get("type"), + pl.get("category"))] += 1 + + print(f"total={len(pts)} skipped(already set)={skipped}") + print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}") + print("pinned split (scope, source, type, category):") + for k, v in sorted(pinned_examples.items()): + print(" ", k, v) + + if not apply: + print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.") + return + + for scope, ids in per_scope.items(): + if not ids: + continue + c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids) + print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}") + + +if __name__ == "__main__": + main() diff --git a/aria-brain/main.py b/aria-brain/main.py index 6136432..3934954 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -190,6 +190,7 @@ class MemoryIn(BaseModel): pinned: bool = False category: str = "" source: str = "manual" + scope: str = "personal" # system | personal — steuert Bootstrap-Export tags: List[str] = Field(default_factory=list) conversation_id: Optional[str] = None # Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge @@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel): content: Optional[str] = None pinned: Optional[bool] = None category: Optional[str] = None + scope: Optional[str] = None # system | personal tags: Optional[List[str]] = None @@ -214,6 +216,7 @@ class MemoryOut(BaseModel): pinned: bool category: str source: str + scope: str = "personal" tags: List[str] created_at: str updated_at: str @@ -328,6 +331,7 @@ def memory_save(body: MemoryIn): pinned=body.pinned, category=body.category, source=body.source, + scope=body.scope, tags=body.tags, conversation_id=body.conversation_id, attachments=body.attachments or [], @@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate): existing.pinned = body.pinned if body.category is not None: existing.category = body.category + if body.scope is not None: + existing.scope = body.scope if body.tags is not None: existing.tags = body.tags @@ -537,12 +543,23 @@ def memory_import_files(): # Wiederherstellen einer schlanken ARIA nach Wipe. @app.get("/memory/export-bootstrap") -def memory_export_bootstrap(): - """Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download.""" +def memory_export_bootstrap(scope: str = "system"): + """Gibt pinned Memories als JSON zurueck — fuer Browser-Download. + + scope='system' → nur generische Regeln (fuer ein frisches System), + scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte), + scope='all' → alles pinned (Vollbackup). + Default 'system', damit man nicht versehentlich Persoenliches teilt.""" s = store() - pinned = s.list_pinned() + if scope == "all": + pinned = s.list_pinned() + elif scope in ("system", "personal"): + pinned = s.list_pinned_by_scope(scope) + else: + raise HTTPException(400, f"Ungueltiger scope: {scope}") return { - "version": 1, + "version": 2, + "scope": scope, "exported_at": __import__("datetime").datetime.now( __import__("datetime").timezone.utc ).isoformat(), @@ -555,6 +572,7 @@ def memory_export_bootstrap(): "pinned": True, "category": p.category, "source": p.source, + "scope": p.scope, "tags": p.tags, } for p in pinned @@ -564,13 +582,18 @@ def memory_export_bootstrap(): class BootstrapBundle(BaseModel): version: int = 1 + scope: Optional[str] = None # system | personal | all (aus dem Export) memories: List[dict] @app.post("/memory/import-bootstrap") def memory_import_bootstrap(body: BootstrapBundle): - """Loescht alle pinned Memories und importiert die im Bundle. - Cold Memory (unpinned) bleibt unangetastet. + """Importiert ein Bootstrap-Bundle scope-sicher. + + Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import + gehoert — ein System-Import laesst also die persoenlichen pinned Memories + (Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle + (Vollbackup) werden alle pinned ersetzt. Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der Caller soll erst exportieren und dann importieren. @@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle): s = store() e = embedder() - - # Alle aktuell pinned Punkte loeschen from qdrant_client.http import models as qm from memory.vector_store import COLLECTION + + # Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten. + bundle_scope = body.scope + if bundle_scope not in ("system", "personal", "all"): + scopes_in_mems = {m.get("scope", "personal") for m in body.memories} + bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all" + + # Nur die pinned Punkte des betroffenen scope loeschen. + del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))] + if bundle_scope in ("system", "personal"): + del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope))) s.client.delete( collection_name=COLLECTION, - points_selector=qm.FilterSelector(filter=qm.Filter(must=[ - qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)) - ])), + points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)), ) - # Neue Punkte einspeisen + # Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal). created = 0 for m in body.memories: content = (m.get("content") or "").strip() if not content: continue + mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal") point = MemoryPoint( id="", type=m.get("type", "fact"), @@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle): pinned=True, category=m.get("category", ""), source=m.get("source", "bootstrap-import"), + scope=mscope, tags=list(m.get("tags", [])), ) vec = e.embed(content) s.upsert(point, vec) created += 1 - return {"created": created, "deleted_previous_pinned": True} + return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True} # ─── Conversation-Loop ────────────────────────────────────────────── diff --git a/aria-brain/memory/vector_store.py b/aria-brain/memory/vector_store.py index 3a575ac..9e5c2ca 100644 --- a/aria-brain/memory/vector_store.py +++ b/aria-brain/memory/vector_store.py @@ -11,6 +11,10 @@ Punkt-Schema (Payload): content — eigentlicher Text (wird embedded) pinned — bool, True = Hot Memory (immer in Prompt) source — import | conversation | manual + scope — system | personal. system = generische Regeln, die JEDER + braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit, + Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs- + daten, Projekte). Steuert den getrennten Bootstrap-Export. tags — Liste von Strings created_at, updated_at — ISO-Strings conversation_id — optional, nur fuer type=conversation @@ -55,6 +59,7 @@ class MemoryPoint: pinned: bool = False category: str = "" source: str = "manual" + scope: str = "personal" # system | personal — steuert Bootstrap-Export tags: List[str] = field(default_factory=list) created_at: str = "" updated_at: str = "" @@ -74,6 +79,7 @@ class MemoryPoint: "pinned": self.pinned, "category": self.category, "source": self.source, + "scope": self.scope, "tags": self.tags, "created_at": self.created_at, "updated_at": self.updated_at, @@ -94,6 +100,7 @@ class MemoryPoint: pinned=payload.get("pinned", False), category=payload.get("category", ""), source=payload.get("source", "manual"), + scope=payload.get("scope", "personal"), tags=payload.get("tags", []), created_at=payload.get("created_at", ""), updated_at=payload.get("updated_at", ""), @@ -120,14 +127,23 @@ class VectorStore: collection_name=COLLECTION, vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE), ) - # Indexe fuer typische Filter-Felder - for field_name in ("type", "pinned", "category", "source", "migration_key"): + # Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf + # einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope). + self._ensure_indexes() + + def _ensure_indexes(self): + for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"): + schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned" + else qm.PayloadSchemaType.KEYWORD) + try: self.client.create_payload_index( collection_name=COLLECTION, field_name=field_name, - field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned" - else qm.PayloadSchemaType.BOOL, + field_schema=schema, ) + except Exception: + # Index existiert bereits — kein Problem. + pass # ─── Schreib-Operationen ───────────────────────────────────────── @@ -164,6 +180,38 @@ class VectorStore: qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)) ])) + def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]: + """Alle pinned Punkte eines scope (system | personal). Fuer den + getrennten Bootstrap-Export.""" + return self._scroll(filter=qm.Filter(must=[ + qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)), + qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)), + ])) + + def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]: + """Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den + System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs- + daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via + memory_search — statt Stefan nach etwas zu fragen, das schon da ist. + + Bewusst NUR die deliberat gespeicherten Punkte: + - nicht pinned (die sind eh schon voll im Prompt), + - kein type=conversation (Chat-Mitschnitte), + - kein source=distilled (die 100e auto-destillierten Gespraechs- + Fakten — die traegt das semantische Auto-Retrieval, sie hier + als Titel zu listen wuerde nur Kontext fressen). + So bleibt der Index klein (Dutzende statt Hunderte Zeilen).""" + return self._scroll( + filter=qm.Filter( + must_not=[ + qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)), + qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")), + qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")), + ] + ), + limit=limit, + ) + def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]: return self._scroll( filter=qm.Filter(must=[ diff --git a/aria-brain/migration.py b/aria-brain/migration.py index 62cb2bb..dda2acc 100644 --- a/aria-brain/migration.py +++ b/aria-brain/migration.py @@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]: type_="preference", title=f"User: {btitle}", content=btext, category="allgemein", migration_key=f"{source_file}/general-{idx}", + scope="personal", )) else: cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein" @@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]: type_="preference", title=title, content=content, category=cat_key, migration_key=f"{source_file}/{cat_key}", + scope="personal", )) return points @@ -283,7 +285,11 @@ def _mk( migration_key: str, pinned: bool = True, category: str = "", + scope: str = "system", ) -> MemoryPoint: + # scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst + # (Identitaet, Sicherheit, Architektur) — das braucht jedes System. + # USER.md-Praeferenzen sind personal und uebergeben scope='personal'. p = MemoryPoint( id="", type=type_, @@ -292,6 +298,7 @@ def _mk( pinned=pinned, category=category, source="import", + scope=scope, tags=[], ) # migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index f895e17..983c221 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict: "pinned": True, "category": rule.get("category", ""), "source": "seed", + "scope": "system", "tags": [], "created_at": now, "updated_at": now, diff --git a/diagnostic/index.html b/diagnostic/index.html index d16468f..3c86789 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1080,11 +1080,13 @@
2. Bootstrap-Snapshot (nur pinned)
- Klein und schnell: nur die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON. - Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer. - Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet. + Getrennt nach scope: System = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System. + Persönlich = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat. + Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
- + + +
@@ -1408,6 +1410,11 @@ 📌 Pinned (Hot Memory — IMMER im System-Prompt) + +