feat(brain): memory_save mit attach_paths — ARIA haengt Bilder selbst an
Letzter Baustein vor Stefan's End-to-End-Test:
memory_attachments.attach_from_path(memory_id, src_path):
- Kopiert eine bestehende Datei aus /shared/uploads/ oder
/shared/memory-attachments/ in das Anhang-Verzeichnis der Memory
- Pfadschutz: nur ALLOWED_SOURCE_PREFIXES (/shared/uploads/,
/shared/memory-attachments/) — kein Zugriff auf Root-FS oder
SSH-Keys
- Groessen-Limit wie save_attachment (20 MB Default)
agent.py memory_save:
- Neuer optionaler Parameter `attach_paths: List[str]`
- Nach dem upsert: pro Pfad attach_from_path → Payload update mit
neuen Anhang-Metadaten
- Fehler beim Anhang sind nicht fatal (Memory bleibt gespeichert,
Hinweis in der Tool-Response)
- Tool-Description deutlich erweitert: expliziter Workflow-Hinweis
bei Bildern → erst `Read <pfad>` aufrufen (Claude Code Read ist
multi-modal), Texte/Kennungen/Marken in den content extrahieren,
dann erst memory_save mit attach_paths. Beispiel-Workflow als
Pseudocode mit Cessna 172 / Kennung D-EAAA.
End-to-End-Workflow ist jetzt einarmig moeglich:
User: "Ich hab eine Cessna 172" + Bild im Attachment
ARIA: Read /shared/uploads/aria_xy.jpg → sieht "Kennung D-EAAA"
ARIA: memory_save(content="Stefan besitzt eine Cessna 172,
Kennung D-EAAA, weiss/rot lackiert.",
attach_paths=["/shared/uploads/aria_xy.jpg"])
→ 🧠-Bubble mit Anhang in der App
→ Spaetere Frage "welche Kennung hat mein Flieger?" liefert via
Cold-Memory den Eintrag inkl. Kennung aus dem content
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-4
@@ -225,13 +225,31 @@ META_TOOLS = [
|
|||||||
"Termine, Personen). Cold Memory, kommt nur via Semantic Search "
|
"Termine, Personen). Cold Memory, kommt nur via Semantic Search "
|
||||||
"rein. **Default fuer 'merk-dir-das'-Anfragen.**\n"
|
"rein. **Default fuer 'merk-dir-das'-Anfragen.**\n"
|
||||||
"- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n"
|
"- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n"
|
||||||
"Wenn unsicher: type=fact, pinned=false."
|
"Wenn unsicher: type=fact, pinned=false.\n\n"
|
||||||
|
"### Anhaenge\n"
|
||||||
|
"`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` "
|
||||||
|
"an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt "
|
||||||
|
"ein Foto an, du siehst den Pfad in der User-Message).\n\n"
|
||||||
|
"**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST "
|
||||||
|
"an mit `Read <pfad>` (dein Read-Tool ist multi-modal — es liest Bilder "
|
||||||
|
"wie Vision-API). Extrahiere alles Relevante in den content: sichtbare "
|
||||||
|
"Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, "
|
||||||
|
"auffaellige Details. Dann erst memory_save mit dem extrahierten "
|
||||||
|
"content + attach_paths fuer das Bild. So weisst du beim spaeteren "
|
||||||
|
"Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n"
|
||||||
|
"Beispiel-Workflow:\n"
|
||||||
|
"1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n"
|
||||||
|
"2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n"
|
||||||
|
"3. Du: `memory_save(type='fact', title='Stefans Cessna 172', "
|
||||||
|
"content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, "
|
||||||
|
"weiss/rot lackiert, vor Hangar fotografiert.', "
|
||||||
|
"attach_paths=['/shared/uploads/aria_xy.jpg'])`"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"},
|
"title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"},
|
||||||
"content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search"},
|
"content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
"enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
||||||
@@ -240,6 +258,11 @@ META_TOOLS = [
|
|||||||
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
||||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
||||||
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
||||||
|
"attach_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments/<id>/ kopiert — Originale bleiben.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["title", "content", "type"],
|
"required": ["title", "content", "type"],
|
||||||
},
|
},
|
||||||
@@ -531,6 +554,8 @@ class Agent:
|
|||||||
tags_in = arguments.get("tags") or []
|
tags_in = arguments.get("tags") or []
|
||||||
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
||||||
pinned = bool(arguments.get("pinned", False))
|
pinned = bool(arguments.get("pinned", False))
|
||||||
|
attach_paths_in = arguments.get("attach_paths") or []
|
||||||
|
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
|
||||||
try:
|
try:
|
||||||
from memory import MemoryPoint
|
from memory import MemoryPoint
|
||||||
vec = self.embedder.embed(content)
|
vec = self.embedder.embed(content)
|
||||||
@@ -539,6 +564,30 @@ class Agent:
|
|||||||
pinned=pinned, category=category, source="aria", tags=tags,
|
pinned=pinned, category=category, source="aria", tags=tags,
|
||||||
)
|
)
|
||||||
pid = self.store.upsert(point, vec)
|
pid = self.store.upsert(point, vec)
|
||||||
|
# Anhaenge kopieren + Payload updaten
|
||||||
|
attach_errors: list[str] = []
|
||||||
|
if attach_paths:
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
new_atts = []
|
||||||
|
for src in attach_paths:
|
||||||
|
try:
|
||||||
|
meta = mem_att.attach_from_path(pid, src)
|
||||||
|
new_atts.append(meta)
|
||||||
|
except ValueError as e:
|
||||||
|
attach_errors.append(f"{src}: {e}")
|
||||||
|
if new_atts:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
import datetime as _dt
|
||||||
|
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||||
|
current = self.store.get(pid)
|
||||||
|
current.attachments = (current.attachments or []) + new_atts
|
||||||
|
current.updated_at = now
|
||||||
|
self.store.client.set_payload(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
payload=current.to_payload() | {"updated_at": now},
|
||||||
|
points=[pid],
|
||||||
|
)
|
||||||
saved = self.store.get(pid)
|
saved = self.store.get(pid)
|
||||||
self._pending_events.append({
|
self._pending_events.append({
|
||||||
"type": "memory_saved",
|
"type": "memory_saved",
|
||||||
@@ -549,8 +598,13 @@ class Agent:
|
|||||||
"attachments": saved.attachments or [],
|
"attachments": saved.attachments or [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return (f"OK — Memory '{title}' gespeichert "
|
n_att = len(saved.attachments or [])
|
||||||
f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}).")
|
msg = (f"OK — Memory '{title}' gespeichert "
|
||||||
|
f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}"
|
||||||
|
+ (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").")
|
||||||
|
if attach_errors:
|
||||||
|
msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n - " + "\n - ".join(attach_errors)
|
||||||
|
return msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("memory_save fehlgeschlagen")
|
logger.exception("memory_save fehlgeschlagen")
|
||||||
return f"FEHLER beim Speichern: {e}"
|
return f"FEHLER beim Speichern: {e}"
|
||||||
|
|||||||
@@ -135,3 +135,38 @@ def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
|
|||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
return None
|
return None
|
||||||
return target.read_bytes()
|
return target.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path —
|
||||||
|
# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files
|
||||||
|
# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf
|
||||||
|
# /root, /etc, /tmp, ssh-Keys, etc.
|
||||||
|
ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/")
|
||||||
|
|
||||||
|
|
||||||
|
def attach_from_path(memory_id: str, source_path: str) -> dict:
|
||||||
|
"""Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis
|
||||||
|
des Memories und gibt die neue Metadaten zurueck.
|
||||||
|
|
||||||
|
Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_<id>.jpg`.
|
||||||
|
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
|
||||||
|
sie es via `memory_save(..., attach_paths=[<src>])` ins Memory-Verzeichnis.
|
||||||
|
|
||||||
|
Pfadschutz: source_path MUSS unter /shared/ liegen — kein Zugriff auf
|
||||||
|
Root-FS, SSH-Keys etc.
|
||||||
|
"""
|
||||||
|
if not memory_id:
|
||||||
|
raise ValueError("memory_id ist Pflicht")
|
||||||
|
if not source_path or not isinstance(source_path, str):
|
||||||
|
raise ValueError("source_path leer")
|
||||||
|
if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
|
||||||
|
raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen")
|
||||||
|
src = Path(source_path)
|
||||||
|
if not src.is_file():
|
||||||
|
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
||||||
|
size = src.stat().st_size
|
||||||
|
if size > MAX_BYTES:
|
||||||
|
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
|
||||||
|
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
||||||
|
data = src.read_bytes()
|
||||||
|
return save_attachment(memory_id, src.name, data)
|
||||||
|
|||||||
Reference in New Issue
Block a user