""" Brain Dump Sanctuary - Core Pipeline (Day 1 MVP + LangChain Integration) Now with Neo4j Graph Database Backend Runs entirely on CPU - no GPU needed """ import numpy as np from sentence_transformers import SentenceTransformer from sklearn.cluster import HDBSCAN import umap import plotly.graph_objects as go from datetime import datetime import json import os from dotenv import load_dotenv from neo4j import GraphDatabase from neo4j.exceptions import ServiceUnavailable, AuthError # Load environment variables from .env file load_dotenv() # LangChain imports for cluster labeling with Gemini from langchain_google_genai import ChatGoogleGenerativeAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnableWithFallbacks # ============== 1. DATABASE LAYER - Neo4j ============== class BrainDumpDB: """ Neo4j-based graph database for Brain Dump Sanctuary. Graph Schema: - Node: Dump {id, text, embedding[], cluster_id, created_at} - Node: Cluster {id, label, description, created_at} - Relationship: Dump -[:IN_CLUSTER]-> Cluster - Relationship: Dump -[:SIMILAR_TO {weight}]-> Dump """ def __init__(self): """Initialize Neo4j connection from environment variables.""" self.uri = os.getenv("NEO4J_URI", "neo4j://localhost:7687") self.user = os.getenv("NEO4J_USER", "neo4j") self.password = os.getenv("NEO4J_PASSWORD", "password") try: self.driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password)) self.driver.verify_connectivity() print(f"✓ Connected to Neo4j at {self.uri}") except (ServiceUnavailable, AuthError) as e: print(f"✗ Failed to connect to Neo4j: {e}") print(" Make sure NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD are set in .env") raise self._init_schema() def _init_schema(self): """Initialize graph schema with nodes and indexes.""" with self.driver.session() as session: # Create Dump nodes with indexes session.run(""" CREATE INDEX dump_id_index IF NOT EXISTS FOR (d:Dump) ON (d.id) """) session.run(""" CREATE INDEX dump_created_index IF NOT EXISTS FOR (d:Dump) ON (d.created_at) """) # Create Cluster nodes with indexes session.run(""" CREATE INDEX cluster_id_index IF NOT EXISTS FOR (c:Cluster) ON (c.id) """) print("✓ Graph schema initialized") def check_duplicate(self, text): """ Check if a dump with the same text already exists in the database. Returns the dump_id if duplicate found, None otherwise. """ with self.driver.session() as session: result = session.run(""" MATCH (d:Dump {text: $text}) RETURN d.id as dump_id LIMIT 1 """, text=text) row = result.single() if row: return row["dump_id"] return None def add_dump(self, text): """ Add a new brain dump to the database. Returns (dump_id, is_duplicate) tuple. If duplicate exists, returns (existing_dump_id, True). If new dump created, returns (new_dump_id, False). """ # Check for duplicate first existing_id = self.check_duplicate(text) if existing_id: return (existing_id, True) # No duplicate, create new dump with self.driver.session() as session: result = session.run(""" CREATE (d:Dump { id: randomUuid(), text: $text, created_at: datetime() }) RETURN d.id as dump_id """, text=text) dump_id = result.single()["dump_id"] return (dump_id, False) def get_all_dumps(self): """Get all dumps with their cluster assignments and timestamps.""" with self.driver.session() as session: result = session.run(""" MATCH (d:Dump) OPTIONAL MATCH (d)-[:IN_CLUSTER]->(c:Cluster) RETURN d.id as id, d.text as text, c.id as cluster_id, d.created_at as created_at ORDER BY d.created_at DESC """) return [(row["id"], row["text"], row["cluster_id"], row["created_at"]) for row in result] def update_embedding(self, dump_id, embedding): """Store embedding vector for a dump.""" # Convert numpy array to list for Neo4j storage embedding_list = embedding.astype(np.float32).tolist() with self.driver.session() as session: session.run(""" MATCH (d:Dump {id: $dump_id}) SET d.embedding = $embedding """, dump_id=dump_id, embedding=embedding_list) def update_cluster(self, dump_id, cluster_id): """Assign dump to a cluster.""" with self.driver.session() as session: # Remove existing cluster relationship session.run(""" MATCH (d:Dump {id: $dump_id})-[r:IN_CLUSTER]->() DELETE r """, dump_id=dump_id) # Create or match cluster node and add relationship session.run(""" MATCH (d:Dump {id: $dump_id}) MERGE (c:Cluster {id: $cluster_id}) ON CREATE SET c.created_at = datetime() CREATE (d)-[:IN_CLUSTER]->(c) """, dump_id=dump_id, cluster_id=int(cluster_id)) def get_embeddings(self): """Retrieve all embeddings from database.""" with self.driver.session() as session: result = session.run(""" MATCH (d:Dump) WHERE d.embedding IS NOT NULL RETURN d.id as dump_id, d.embedding as embedding """) embeddings = [] for row in result: dump_id = row["dump_id"] embedding_list = row["embedding"] # Convert list back to numpy array embedding = np.array(embedding_list, dtype=np.float32) embeddings.append((dump_id, embedding)) return embeddings def save_cluster_label(self, cluster_id, label, description=None): """Save or update a cluster label.""" with self.driver.session() as session: session.run(""" MERGE (c:Cluster {id: $cluster_id}) ON CREATE SET c.created_at = datetime() SET c.label = $label, c.description = $description """, cluster_id=int(cluster_id), label=label, description=description) def get_cluster_label(self, cluster_id): """Get label for a specific cluster.""" with self.driver.session() as session: result = session.run(""" MATCH (c:Cluster {id: $cluster_id}) RETURN c.label as label, c.description as description """, cluster_id=int(cluster_id)) row = result.single() if row: return (row["label"], row["description"]) return (None, None) def get_all_cluster_labels(self): """Get all cluster labels.""" with self.driver.session() as session: result = session.run(""" MATCH (c:Cluster) RETURN c.id as cluster_id, c.label as label, c.description as description """) return { row["cluster_id"]: { "label": row["label"], "description": row["description"] } for row in result } def get_cluster_dumps(self, cluster_id): """Get all dumps in a specific cluster.""" with self.driver.session() as session: result = session.run(""" MATCH (d:Dump)-[:IN_CLUSTER]->(c:Cluster {id: $cluster_id}) RETURN d.id as dump_id, d.text as text """, cluster_id=int(cluster_id)) return [(row["dump_id"], row["text"]) for row in result] def add_generated_dump(self, text, cluster_id): """ Add a generated braindump directly to a specific cluster. Returns (dump_id, is_duplicate) tuple. If duplicate exists, returns (existing_dump_id, True) without adding to cluster. If new dump created, returns (new_dump_id, False). """ # Check for duplicate first existing_id = self.check_duplicate(text) if existing_id: return (existing_id, True) # No duplicate, create new dump and assign to cluster with self.driver.session() as session: result = session.run(""" CREATE (d:Dump { id: randomUuid(), text: $text, created_at: datetime() }) WITH d MATCH (c:Cluster {id: $cluster_id}) CREATE (d)-[:IN_CLUSTER]->(c) RETURN d.id as dump_id """, text=text, cluster_id=int(cluster_id)) dump_id = result.single()["dump_id"] return (dump_id, False) def save_feed_cache(self, dump_id, summary, questions, image_urls=None): """ Store cached feed data (summary, questions, and image URLs) for a dump. Args: dump_id: ID of the dump summary: Summary text from FeedAgent questions: List of reflection questions image_urls: List of image URLs related to the brain dump """ with self.driver.session() as session: # Store as JSON strings for Neo4j compatibility questions_json = json.dumps(questions) if isinstance(questions, list) else questions image_urls_json = json.dumps(image_urls) if image_urls else json.dumps([]) session.run(""" MATCH (d:Dump {id: $dump_id}) SET d.summary = $summary, d.questions = $questions, d.image_urls = $image_urls, d.feed_cache_generated_at = datetime() """, dump_id=dump_id, summary=summary, questions=questions_json, image_urls=image_urls_json) def get_feed_cache(self, dump_id): """ Retrieve cached feed data for a dump. Returns: {"summary": str, "questions": list, "image_urls": list} or None if not cached """ with self.driver.session() as session: result = session.run(""" MATCH (d:Dump {id: $dump_id}) RETURN d.summary as summary, d.questions as questions, d.image_urls as image_urls """, dump_id=dump_id) row = result.single() if row and row["summary"]: questions = json.loads(row["questions"]) if row["questions"] else [] image_urls = json.loads(row["image_urls"]) if row["image_urls"] else [] return { "summary": row["summary"], "questions": questions, "image_urls": image_urls } return None def close(self): """Close database connection.""" self.driver.close() # ============== 2. EMBEDDING ENGINE ============== class EmbeddingEngine: def __init__(self, model_name='all-MiniLM-L6-v2'): print(f"Loading embedding model: {model_name}") self.model = SentenceTransformer(model_name) print("✓ Model loaded (CPU mode)") def embed(self, texts): """Fast CPU inference - 5-10ms per text""" if isinstance(texts, str): texts = [texts] embeddings = self.model.encode(texts, show_progress_bar=True) return embeddings # ============== 3. CLUSTERING ENGINE ============== class ClusterEngine: def __init__(self, min_cluster_size=3): self.min_cluster_size = min_cluster_size self.clusterer = None self.reducer = None self.llm = None self.labeling_chain = None # Initialize LangChain for cluster labeling self._init_labeling_chain() def _init_labeling_chain(self): """Initialize LangChain chain for automatic cluster labeling with Google Gemini""" try: api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: print("⚠️ GOOGLE_API_KEY not found. Cluster labeling will be disabled.") print(" Get your API key from: https://aistudio.google.com/app/apikey") return # Create primary Gemini LLM primary_llm = ChatGoogleGenerativeAI( model="gemini-2.5-flash", temperature=0.3, google_api_key=api_key ) # Create fallback Gemini LLM (using same model but could use gemini-pro) fallback_llm = ChatGoogleGenerativeAI( model="gemini-2.5-flash", temperature=0.5, google_api_key=api_key ) # LLM with fallback support self.llm = primary_llm.with_fallbacks([fallback_llm]) # Create labeling prompt prompt = ChatPromptTemplate.from_template( """You are an expert at identifying themes in clusters of related thoughts. Given these brain dump entries from a cluster: {cluster_texts} Task: Generate a short, descriptive label (2-4 words) that captures the unifying theme. Rules: - Be specific and insightful - Use natural language, not generic terms - Focus on the underlying curiosity or topic - Be specific if entries are less. - Be broad if entries are more. Label:""" ) # Create the chain self.labeling_chain = ( prompt | self.llm | StrOutputParser() ) print("✓ LangChain cluster labeling initialized with Google Gemini") except Exception as e: print(f"⚠️ Could not initialize cluster labeling: {e}") self.llm = None self.labeling_chain = None def generate_cluster_label(self, cluster_texts): """Generate a label for a cluster using LangChain""" if not self.labeling_chain: return "Cluster" try: # Take up to 5 representative texts sample_texts = cluster_texts[:5] texts_str = "\n".join([f"- {text}" for text in sample_texts]) label = self.labeling_chain.invoke({"cluster_texts": texts_str}) return label.strip() except Exception as e: print(f"⚠️ Cluster labeling failed: {e}") return "Cluster" def fit_predict(self, embeddings): """HDBSCAN clustering - works great on CPU""" print(f"Clustering {len(embeddings)} brain dumps...") n_samples = len(embeddings) # Adjust min_cluster_size for small datasets effective_min_cluster_size = min(self.min_cluster_size, max(2, n_samples // 2)) # HDBSCAN for semantic clustering self.clusterer = HDBSCAN( min_cluster_size=effective_min_cluster_size, metric='euclidean', cluster_selection_method='eom', min_samples=1 # More lenient clustering ) clusters = self.clusterer.fit_predict(embeddings) # UMAP for 2D visualization with better separation parameters # n_neighbors controls local vs global structure (lower = tighter clusters) n_neighbors = max(2, min(10, n_samples - 1)) # Reduced from 15 for tighter clusters # For very small datasets, use simpler initialization init = 'spectral' if n_samples > 10 else 'random' self.reducer = umap.UMAP( n_components=2, random_state=42, n_neighbors=n_neighbors, min_dist=0.3, # Increased from 0.1 for better separation between clusters spread=1.5, # Controls how clumped embeddings are (higher = more spread) metric='euclidean', init=init, negative_sample_rate=10, # Helps with separation repulsion_strength=1.2 # Pushes dissimilar points apart ) coords_2d = self.reducer.fit_transform(embeddings) print(f"✓ Found {len(set(clusters)) - (1 if -1 in clusters else 0)} clusters") return clusters, coords_2d # ============== 4. VISUALIZATION ============== def build_graph_edges(embeddings, clusters, max_edges_per_node=5, similarity_threshold=0.7): """Build edges between semantically similar items (Obsidian knowledge graph style)""" from sklearn.metrics.pairwise import cosine_similarity edges = [] similarities = cosine_similarity(embeddings) # Add edges between items in same cluster unique_clusters = set(clusters) for cluster_id in unique_clusters: if cluster_id == -1: # Skip noise continue cluster_indices = np.where(clusters == cluster_id)[0] # Connect items within cluster (up to max_edges_per_node each) for i in cluster_indices: # Find most similar items in same cluster cluster_similarities = [ (j, similarities[i][j]) for j in cluster_indices if i != j ] cluster_similarities.sort(key=lambda x: x[1], reverse=True) for j, sim in cluster_similarities[:max_edges_per_node]: if i < j: # Avoid duplicates edges.append((i, j, sim)) return edges def create_knowledge_graph(dump_data, coords_2d, clusters, cluster_labels=None, embeddings=None): """ Obsidian-style knowledge graph visualization with force-directed layout. Shows connections between related brain dumps in a network style. """ fig = go.Figure() # Color palette for clusters cluster_colors = [ '#FF6B6B', # Red '#4ECDC4', # Teal '#45B7D1', # Sky Blue '#FFA07A', # Light Salmon '#98D8C8', # Mint '#F7DC6F', # Yellow '#BB8FCE', # Purple '#85C1E2', # Light Blue '#F8B739', # Orange '#52BE80', # Green '#EC7063', # Coral '#AF7AC5', # Lavender '#5DADE2', # Ocean Blue '#48C9B0', # Turquoise '#F1948A', # Pink '#85929E', # Gray Blue '#F39C12', # Dark Orange '#3498DB', # Bright Blue '#E74C3C', # Bright Red '#9B59B6' # Violet ] # Color map for clusters unique_clusters = set(clusters) colors = {-1: '#95A5A6'} # Gray for noise points for i, c in enumerate([c for c in unique_clusters if c != -1]): colors[c] = cluster_colors[i % len(cluster_colors)] # Build edges between related items edges = [] if embeddings is not None: edges = build_graph_edges(embeddings, clusters) # Draw edges first (so they appear behind nodes) for i, j, similarity in edges: x0, y0 = coords_2d[i] x1, y1 = coords_2d[j] # Edge opacity based on similarity edge_opacity = 0.2 + (similarity - 0.7) * 0.4 # Range: 0.2-0.6 edge_opacity = max(0.1, min(0.6, edge_opacity)) fig.add_trace(go.Scatter( x=[x0, x1, None], y=[y0, y1, None], mode='lines', line=dict( width=1.5, color=f'rgba(200, 200, 200, {edge_opacity})', ), hoverinfo='none', showlegend=False, name='', )) # Draw nodes (clusters) for cluster_id in unique_clusters: mask = clusters == cluster_id cluster_coords = coords_2d[mask] cluster_texts = [dump_data[i][1] for i in range(len(dump_data)) if clusters[i] == cluster_id] # Get cluster label if available if cluster_labels and cluster_id in cluster_labels: label = cluster_labels[cluster_id] else: label = f"Cluster {cluster_id}" if cluster_id != -1 else "Unclustered" # Determine node size (larger for bigger clusters) node_size = min(24, 14 + len(cluster_texts) // 2) fig.add_trace(go.Scatter( x=cluster_coords[:, 0], y=cluster_coords[:, 1], mode='markers', name=label, marker=dict( size=node_size, color=colors[cluster_id], line=dict(width=2, color='rgba(255, 255, 255, 0.8)'), opacity=0.95, symbol='circle', ), text=cluster_texts, hovertext=[f"{text}" for text in cluster_texts], hoverinfo='text', showlegend=True, )) fig.update_layout( title={ 'text': "🧠 Brain Dump Sanctuary - Knowledge Graph", 'x': 0.5, 'xanchor': 'center', 'font': {'size': 20, 'color': 'white'} }, showlegend=True, hovermode='closest', width=1400, height=800, plot_bgcolor='#0d1117', paper_bgcolor='#0d1117', font=dict(color='white', family='monospace'), xaxis=dict( showgrid=False, zeroline=False, showticklabels=False, showline=False, ), yaxis=dict( showgrid=False, zeroline=False, showticklabels=False, showline=False, ), margin=dict(l=0, r=200, t=50, b=0), legend=dict( orientation="v", yanchor="top", y=0.99, xanchor="left", x=1.02, bgcolor='rgba(13, 17, 23, 0.9)', bordercolor='#30363d', borderwidth=1, font=dict(size=11), ), ) return fig def create_cluster_graph(dump_data, coords_2d, clusters, cluster_labels=None): """Interactive Plotly visualization with cluster labels (Legacy - use create_knowledge_graph instead)""" fig = go.Figure() # Expanded color palette with 20 distinct colors cluster_colors = [ '#FF6B6B', # Red '#4ECDC4', # Teal '#45B7D1', # Sky Blue '#FFA07A', # Light Salmon '#98D8C8', # Mint '#F7DC6F', # Yellow '#BB8FCE', # Purple '#85C1E2', # Light Blue '#F8B739', # Orange '#52BE80', # Green '#EC7063', # Coral '#AF7AC5', # Lavender '#5DADE2', # Ocean Blue '#48C9B0', # Turquoise '#F1948A', # Pink '#85929E', # Gray Blue '#F39C12', # Dark Orange '#3498DB', # Bright Blue '#E74C3C', # Bright Red '#9B59B6' # Violet ] # Color map for clusters unique_clusters = set(clusters) colors = {-1: '#95A5A6'} # Gray for noise points for i, c in enumerate([c for c in unique_clusters if c != -1]): colors[c] = cluster_colors[i % len(cluster_colors)] # Plot each cluster for cluster_id in unique_clusters: mask = clusters == cluster_id cluster_coords = coords_2d[mask] cluster_texts = [dump_data[i][1] for i in range(len(dump_data)) if clusters[i] == cluster_id] # Get cluster label if available if cluster_labels and cluster_id in cluster_labels: label = cluster_labels[cluster_id] else: label = f"Cluster {cluster_id}" if cluster_id != -1 else "Unclustered" fig.add_trace(go.Scatter( x=cluster_coords[:, 0], y=cluster_coords[:, 1], mode='markers+text', name=label, marker=dict( size=14, # Slightly larger for visibility color=colors[cluster_id], line=dict(width=2, color='white'), # Thicker white border opacity=0.9 # Slight transparency to see overlaps ), text=[f"{i+1}" for i in range(len(cluster_texts))], textposition="top center", textfont=dict(size=10, color='white'), hovertext=cluster_texts, hoverinfo='text' )) fig.update_layout( title="Brain Dump Sanctuary - Semantic Clusters", showlegend=True, hovermode='closest', width=1200, # Wider for better separation height=700, # Taller for better separation plot_bgcolor='#1a1a1a', # Dark background paper_bgcolor='#0d1117', font=dict(color='white'), xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), yaxis=dict(showgrid=False, zeroline=False, showticklabels=False), legend=dict( orientation="v", yanchor="top", y=1, xanchor="left", x=1.02, bgcolor='rgba(13, 17, 23, 0.8)', bordercolor='#30363d', borderwidth=1 ) ) return fig # ============== 5. DEMO PIPELINE ============== def run_demo(): """Complete pipeline demo""" # Sample brain dumps sample_dumps = [ # Consciousness & Mind (8 items) "Why do dreams feel so real but fade so quickly?", "Is consciousness an emergent property?", "Why do we forget things we just read?", "What causes earworms (songs stuck in head)?", "Why does time feel faster as we age?", "How does anesthesia actually work?", "What makes us self-aware?", "Can machines ever truly be conscious?", # Physics & Quantum (8 items) "How does quantum entanglement actually work?", "Is math discovered or invented?", "What happens at the event horizon of a black hole?", "Why is the speed of light constant?", "What is dark matter made of?", "How can particles be in two places at once?", "What came before the Big Bang?", "Why does time only move forward?", # AI & Technology (8 items) "Are LLMs actually understanding or just pattern matching?", "How do neural networks learn representations?", "What makes a question 'good' vs 'bad'?", "Can AI ever be truly creative?", "How do transformers attend to context?", "What is the halting problem and why does it matter?", "Will we ever achieve AGI?", "How do computers generate random numbers?", # Biology & Nature (8 items) "How do birds navigate during migration?", "What causes the smell of rain on dry ground?", "What's the connection between gut bacteria and mood?", "How do whales communicate across oceans?", "Why do cats purr?", "How do fireflies synchronize their flashing?", "Why do we yawn when others yawn?", "How do octopuses change color instantly?", # Food & Chemistry (8 items) "What makes sourdough bread different from regular bread?", "Why does coffee smell better than it tastes?", "What makes food spicy and why do we like it?", "How does fermentation preserve food?", "Why does chocolate melt at body temperature?", "What causes that metallic taste when you bite foil?", "Why do onions make us cry?", "How do flavors combine to create umami?", # Music & Art (8 items) "Why do some songs give me chills?", "What makes a melody memorable?", "How does rhythm affect our emotions?", "Why do major keys sound happy and minor keys sad?", "What makes abstract art 'good'?", "How does color theory influence mood?", "Why do we find symmetry beautiful?", "What is the golden ratio in design?", # Psychology & Society (8 items) "Why do we procrastinate even when we know better?", "How does confirmation bias shape our beliefs?", "What causes impostor syndrome?", "Why are first impressions so lasting?", "How do echo chambers form online?", "What makes some ideas go viral?", "Why is it hard to change someone's mind?", "How does groupthink override individual judgment?", # Language & Communication (4 items) "Why do different languages have different sounds?", "How did writing systems evolve independently?", "What makes a joke funny across cultures?", "Why do babies learn language so easily?" ] print("=== BRAIN DUMP SANCTUARY - DEMO ===\n") # 1. Initialize db = BrainDumpDB() embedder = EmbeddingEngine() clusterer = ClusterEngine(min_cluster_size=2) # 2. Add dumps to database print("\n[1/4] Adding brain dumps to database...") for dump in sample_dumps: dump_id, is_duplicate = db.add_dump(dump) if is_duplicate: print(f" Skipped duplicate: {dump[:50]}...") # 3. Generate embeddings print("\n[2/4] Generating embeddings (CPU)...") dumps = db.get_all_dumps() texts = [d[1] for d in dumps] embeddings = embedder.embed(texts) for i, (dump_id, text, _, created_at) in enumerate(dumps): db.update_embedding(dump_id, embeddings[i]) # 4. Cluster print("\n[3/5] Performing semantic clustering...") clusters, coords_2d = clusterer.fit_predict(embeddings) for i, (dump_id, _, _, _) in enumerate(dumps): db.update_cluster(dump_id, clusters[i]) # 5. Auto-generate cluster labels using LangChain print("\n[4/5] Generating cluster labels with LLM...") cluster_labels = {} unique_clusters = set(clusters) - {-1} for cluster_id in unique_clusters: cluster_dumps = [dumps[i][1] for i in range(len(dumps)) if clusters[i] == cluster_id] label = clusterer.generate_cluster_label(cluster_dumps) cluster_labels[cluster_id] = label db.save_cluster_label(cluster_id, label) print(f" Cluster {cluster_id}: '{label}'") # 6. Visualize print("\n[5/5] Creating knowledge graph visualization...") fig = create_knowledge_graph(dumps, coords_2d, clusters, cluster_labels, embeddings) fig.write_html("brain_dump_knowledge_graph.html") print("✓ Saved to: brain_dump_knowledge_graph.html") # 7. Show cluster insights print("\n=== CLUSTER INSIGHTS ===") for cluster_id in sorted(unique_clusters): cluster_dumps = [dumps[i][1] for i in range(len(dumps)) if clusters[i] == cluster_id] label = cluster_labels.get(cluster_id, f"Cluster {cluster_id}") print(f"\n{label} ({len(cluster_dumps)} items):") for dump in cluster_dumps[:3]: # Show first 3 print(f" - {dump}") if __name__ == "__main__": run_demo()