Feat: Gedaechtnis-Graph-Ansicht (Reagraph) + /api/memory/graph
Obsidian-artige Visualisierung des Gedaechtnisses: Knoten = Fakten, Kanten = semantische Aehnlichkeit (Kosinus der gespeicherten Embeddings, kNN je Knoten), Farbe = Kategorie, Groesse = Vernetzung. Klick auf Knoten -> Detailpanel mit verwandten Fakten + vergessen. - mem0_service/app.py: /graph rechnet Aehnlichkeitskanten aus den Chroma-Embeddings. - backend: services.memory.graph() + /api/memory/graph (Passthrough). - frontend: GraphView (reagraph, WebGL), Graph/Liste-Umschalter in MemoryView, GraphErrorBoundary, lazy-load (three.js nur bei Bedarf -> Hauptbundle bleibt schlank). reagraph auf 4.22.0 gepinnt (4.23+ braucht @react-three/fiber v9 = React 19; Projekt ist React 18). Live gegen die Box verifiziert (Graph rendert, Kategorien-Farben, Kanten, dunkel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,12 @@ def export() -> dict:
|
|||||||
return memory.export_text()
|
return memory.export_text()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/graph")
|
||||||
|
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||||
|
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
|
||||||
|
return memory.graph(min_score=min_score, top_k=top_k)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/learn", status_code=201)
|
@router.post("/memory/learn", status_code=201)
|
||||||
def learn(body: LearnIn) -> dict:
|
def learn(body: LearnIn) -> dict:
|
||||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def learn(text: str | None = None, messages: list[dict] | None = None,
|
|||||||
"source": source, "category": category})
|
"source": source, "category": category})
|
||||||
|
|
||||||
|
|
||||||
|
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||||
|
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
||||||
|
return _get("/graph", min_score=min_score, top_k=top_k)
|
||||||
|
|
||||||
|
|
||||||
def export_text() -> dict:
|
def export_text() -> dict:
|
||||||
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
||||||
lines = ["# Mission Control — Gedächtnis\n"]
|
lines = ["# Mission Control — Gedächtnis\n"]
|
||||||
|
|||||||
+3956
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+122
-112
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BohGf1r3.js"></script>
|
<script type="module" crossorigin src="/assets/index-DuvhfsJu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-SuFuJOVE.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DEncQp-z.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+1004
-3
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"reagraph": "^4.22.0",
|
||||||
"recharts": "^3.9.0",
|
"recharts": "^3.9.0",
|
||||||
"tailwind-merge": "^2.5.5"
|
"tailwind-merge": "^2.5.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -270,6 +270,17 @@ export interface Memory {
|
|||||||
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
|
score?: number // Relevanz bei semantischer Suche (q gesetzt); sonst undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemoryGraphNode {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
category: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
export interface MemoryGraph {
|
||||||
|
nodes: MemoryGraphNode[]
|
||||||
|
edges: { source: string; target: string; weight: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DedupeResult {
|
export interface DedupeResult {
|
||||||
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
|
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
|
||||||
duplicate_count: number
|
duplicate_count: number
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type Health,
|
type Health,
|
||||||
type Job,
|
type Job,
|
||||||
type Memory,
|
type Memory,
|
||||||
|
type MemoryGraph,
|
||||||
type ModelsResp,
|
type ModelsResp,
|
||||||
type RoutingResp,
|
type RoutingResp,
|
||||||
type ServicesResp,
|
type ServicesResp,
|
||||||
@@ -37,8 +38,16 @@ export const qk = {
|
|||||||
drafts: (target?: string) => ["drafts", target ?? ""] as const,
|
drafts: (target?: string) => ["drafts", target ?? ""] as const,
|
||||||
connect: (params?: string) => ["connect", params ?? ""] as const,
|
connect: (params?: string) => ["connect", params ?? ""] as const,
|
||||||
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
||||||
|
memoryGraph: ["memory-graph"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useMemoryGraph = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: qk.memoryGraph,
|
||||||
|
queryFn: () => api<MemoryGraph>("/api/memory/graph"),
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
export const useHealth = () =>
|
export const useHealth = () =>
|
||||||
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
|
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Component, type ReactNode } from "react"
|
||||||
|
|
||||||
|
export class GraphErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||||
|
state = { error: null as Error | null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="h-[480px] rounded-2xl border border-red-500/20 bg-red-500/5 p-6 text-xs text-red-400 overflow-auto">
|
||||||
|
<div className="font-semibold mb-2">Graph konnte nicht gerendert werden</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed">{this.state.error.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { GraphCanvas, darkTheme } from "reagraph"
|
||||||
|
import { Trash2, Sparkles, Share2 } from "lucide-react"
|
||||||
|
import { type MemoryGraph } from "@/lib/api"
|
||||||
|
|
||||||
|
const CAT_COLOR: Record<string, string> = {
|
||||||
|
user: "#22d3ee", instruction: "#a78bfa", stable: "#6366f1", versioned: "#fbbf24", ephemeral: "#f472b6",
|
||||||
|
}
|
||||||
|
const CAT_LABEL: Record<string, string> = {
|
||||||
|
user: "User", instruction: "Regel", stable: "Fakt", versioned: "Version", ephemeral: "Temporär",
|
||||||
|
}
|
||||||
|
const AUTO = new Set(["auto", "agent", "hermes"])
|
||||||
|
|
||||||
|
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const degree = useMemo(() => {
|
||||||
|
const d: Record<string, number> = {}
|
||||||
|
data.edges.forEach((e) => { d[e.source] = (d[e.source] || 0) + 1; d[e.target] = (d[e.target] || 0) + 1 })
|
||||||
|
return d
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const nodes = useMemo(() => data.nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
label: n.content.length > 26 ? n.content.slice(0, 25) + "…" : n.content,
|
||||||
|
fill: CAT_COLOR[n.category] || "#64748b",
|
||||||
|
size: 6 + Math.min(degree[n.id] || 0, 6) * 2,
|
||||||
|
})), [data, degree])
|
||||||
|
|
||||||
|
const edges = useMemo(() => data.edges.map((e) => ({
|
||||||
|
id: `${e.source}->${e.target}`,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
size: 0.4 + e.weight,
|
||||||
|
})), [data])
|
||||||
|
|
||||||
|
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
|
||||||
|
const neighbors = useMemo(() => {
|
||||||
|
if (!selected) return []
|
||||||
|
const ids = new Set<string>()
|
||||||
|
data.edges.forEach((e) => {
|
||||||
|
if (e.source === selected) ids.add(e.target)
|
||||||
|
if (e.target === selected) ids.add(e.source)
|
||||||
|
})
|
||||||
|
return data.nodes.filter((n) => ids.has(n.id))
|
||||||
|
}, [selected, data])
|
||||||
|
|
||||||
|
if (!data.nodes.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
|
||||||
|
Noch keine Fakten — der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-3">
|
||||||
|
<div className="relative h-[480px] rounded-2xl border border-border/60 bg-[#070a0f] overflow-hidden">
|
||||||
|
<GraphCanvas
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
theme={darkTheme}
|
||||||
|
layoutType="forceDirected2d"
|
||||||
|
labelType="nodes"
|
||||||
|
edgeArrowPosition="none"
|
||||||
|
draggable
|
||||||
|
onNodeClick={(n: any) => setSelected(n.id)}
|
||||||
|
onCanvasClick={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 min-h-[480px]">
|
||||||
|
{sel ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ background: CAT_COLOR[sel.category], boxShadow: `0 0 0 2px ${AUTO.has(sel.source) ? "#34d399" : "#475569"}` }} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}>
|
||||||
|
{CAT_LABEL[sel.category] || sel.category}
|
||||||
|
</span>
|
||||||
|
<span className={`ml-auto text-[10px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
|
||||||
|
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}{sel.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-foreground leading-relaxed mb-4 break-words">{sel.content}</div>
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 mb-2 flex items-center gap-1">
|
||||||
|
<Share2 className="h-3 w-3" /> verwandte Fakten
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 mb-4">
|
||||||
|
{neighbors.length ? neighbors.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => setSelected(n.id)}
|
||||||
|
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
|
||||||
|
<span className="truncate">{n.content}</span>
|
||||||
|
</button>
|
||||||
|
)) : <span className="text-[11px] text-muted-foreground/60">—</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { onDelete(sel.id); setSelected(null) }}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> vergessen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
|
||||||
|
<Share2 className="h-6 w-6" />
|
||||||
|
<div className="text-xs max-w-[180px] leading-relaxed">
|
||||||
|
Einen Knoten wählen, um den Fakt und seine semantischen Nachbarn zu sehen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState } from "react"
|
import { useState, lazy, Suspense } from "react"
|
||||||
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
|
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen, Share2, List } from "lucide-react"
|
||||||
import { api, type DedupeResult } from "@/lib/api"
|
import { api, type DedupeResult } from "@/lib/api"
|
||||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
import { useMemory, useMemoryGraph, useQueryClient } from "@/lib/queries"
|
||||||
import { useDialog } from "@/lib/useDialog"
|
import { useDialog } from "@/lib/useDialog"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { GraphErrorBoundary } from "./GraphErrorBoundary"
|
||||||
|
|
||||||
|
// reagraph + three.js sind schwer → erst laden, wenn der Graph-Tab geöffnet wird.
|
||||||
|
const GraphView = lazy(() => import("./GraphView").then((m) => ({ default: m.GraphView })))
|
||||||
|
|
||||||
|
|
||||||
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
||||||
|
|
||||||
@@ -35,13 +40,18 @@ export function MemoryView() {
|
|||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
const [category, setCategory] = useState("stable")
|
const [category, setCategory] = useState("stable")
|
||||||
const [deduping, setDeduping] = useState(false)
|
const [deduping, setDeduping] = useState(false)
|
||||||
|
const [view, setView] = useState<"liste" | "graph">("liste")
|
||||||
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||||
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
|
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
|
||||||
|
const { data: graph } = useMemoryGraph(view === "graph")
|
||||||
const error = itemsErr ? String(itemsErr) : ""
|
const error = itemsErr ? String(itemsErr) : ""
|
||||||
|
|
||||||
const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
|
const reloadMemory = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["memory"] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["memory-graph"] })
|
||||||
|
}
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
if (!content.trim()) return
|
if (!content.trim()) return
|
||||||
@@ -104,15 +114,33 @@ export function MemoryView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 self-start">
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setView("liste")}
|
||||||
|
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
|
||||||
|
view === "liste" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" /> Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView("graph")}
|
||||||
|
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
|
||||||
|
view === "graph" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
>
|
||||||
|
<Share2 className="h-3.5 w-3.5" /> Graph
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={cleanup}
|
onClick={cleanup}
|
||||||
disabled={deduping}
|
disabled={deduping}
|
||||||
className="flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50 shrink-0 self-start"
|
className="flex h-9 px-4 items-center gap-1.5 text-xs font-semibold rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 text-primary animate-pulse" />
|
<Sparkles className="h-4 w-4 text-primary animate-pulse" />
|
||||||
<span>Deduplizieren</span>
|
<span>Deduplizieren</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Add New Fact Box */}
|
{/* Add New Fact Box */}
|
||||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
|
||||||
@@ -151,6 +179,14 @@ export function MemoryView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{view === "graph" ? (
|
||||||
|
<GraphErrorBoundary>
|
||||||
|
<Suspense fallback={<div className="h-[480px] rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen…</div>}>
|
||||||
|
<GraphView data={graph ?? { nodes: [], edges: [] }} onDelete={del} />
|
||||||
|
</Suspense>
|
||||||
|
</GraphErrorBoundary>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Filter / Search HUD */}
|
{/* Filter / Search HUD */}
|
||||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -264,6 +300,8 @@ export function MemoryView() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{dialogElement}
|
{dialogElement}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,6 +223,60 @@ def delete_memory(mid: str) -> dict:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/graph")
|
||||||
|
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||||
|
"""Fakten als Graph: Knoten = Fakten, Kanten = semantische Ähnlichkeit (Kosinus
|
||||||
|
der gespeicherten Embeddings, je Knoten die top_k Nachbarn ≥ min_score). Für die
|
||||||
|
Obsidian-artige Gedächtnis-Visualisierung im UI."""
|
||||||
|
items = mem().get_all(filters={"user_id": USER_ID}, top_k=2000).get("results", [])
|
||||||
|
nodes = [_to_item(r) for r in items]
|
||||||
|
node_ids = [n["id"] for n in nodes]
|
||||||
|
idx = {nid: i for i, nid in enumerate(node_ids)}
|
||||||
|
|
||||||
|
# Embeddings direkt aus der Chroma-Collection ziehen (kein Re-Embedding).
|
||||||
|
col = mem().vector_store.collection
|
||||||
|
raw = col.get(include=["embeddings"])
|
||||||
|
raw_ids = raw.get("ids") or []
|
||||||
|
raw_embs = raw.get("embeddings")
|
||||||
|
edges: list[dict] = []
|
||||||
|
vecs = [None] * len(node_ids)
|
||||||
|
have = 0
|
||||||
|
if raw_embs is not None:
|
||||||
|
for rid, emb in zip(raw_ids, raw_embs):
|
||||||
|
if rid in idx and emb is not None:
|
||||||
|
vecs[idx[rid]] = emb
|
||||||
|
have += 1
|
||||||
|
|
||||||
|
if have >= 2:
|
||||||
|
import numpy as np
|
||||||
|
present = [i for i, v in enumerate(vecs) if v is not None]
|
||||||
|
M = np.array([vecs[i] for i in present], dtype=float)
|
||||||
|
norms = np.linalg.norm(M, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1.0
|
||||||
|
Mn = M / norms
|
||||||
|
sim = Mn @ Mn.T
|
||||||
|
seen: set[tuple[int, int]] = set()
|
||||||
|
for a in range(len(present)):
|
||||||
|
order = np.argsort(-sim[a])
|
||||||
|
cnt = 0
|
||||||
|
for b in order:
|
||||||
|
if b == a:
|
||||||
|
continue
|
||||||
|
s = float(sim[a][b])
|
||||||
|
if s < min_score:
|
||||||
|
break
|
||||||
|
i, j = present[a], present[b]
|
||||||
|
key = (min(i, j), max(i, j))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
edges.append({"source": node_ids[i], "target": node_ids[j],
|
||||||
|
"weight": round(s, 3)})
|
||||||
|
cnt += 1
|
||||||
|
if cnt >= top_k:
|
||||||
|
break
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/learn")
|
@app.post("/learn")
|
||||||
def learn(body: LearnIn) -> dict:
|
def learn(body: LearnIn) -> dict:
|
||||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst
|
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 EXTRAHIERT Fakten selbst
|
||||||
|
|||||||
Reference in New Issue
Block a user