Kshitij Nevrekar commited on
Commit
e1af63d
Β·
1 Parent(s): bb74951

upload from github

Browse files
Files changed (11) hide show
  1. .env.example +22 -0
  2. .gitignore +217 -0
  3. LICENSE +21 -0
  4. README.md +670 -15
  5. agents.py +707 -0
  6. app.py +628 -0
  7. braindump_core.py +850 -0
  8. neo4j_maintenance.py +102 -0
  9. requirements.txt +31 -3
  10. src/streamlit_app.py +0 -40
  11. test_duplicate_detection.py +59 -0
.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Neo4j Aura Database Configuration
2
+ # Get these values from your Neo4j Aura instance
3
+ # Visit: https://console.neo4j.io/
4
+
5
+ # Neo4j Connection String
6
+ # Format: neo4j+ssc://xxxxxxxx.databases.neo4j.io or neo4j://hostname:port
7
+ NEO4J_URI=neo4j+ssc://your-instance-id.databases.neo4j.io
8
+
9
+ # Neo4j Username (usually 'neo4j')
10
+ NEO4J_USER=neo4j
11
+
12
+ # Neo4j Password (from Aura instance creation)
13
+ NEO4J_PASSWORD=your-secure-password-here
14
+
15
+ # Google Gemini API Configuration
16
+ # Get your free API key from: https://aistudio.google.com/app/apikey
17
+ GOOGLE_API_KEY=your_google_api_key_here
18
+
19
+ # Optional: Tavily Web Search API
20
+ # Get your API key from: https://app.tavily.com
21
+ # Leave empty to use mock data for Search Agent
22
+ TAVILY_API_KEY=your_tavily_api_key_here
.gitignore ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .github/
2
+ .vscode/
3
+
4
+ # .DS_Store
5
+ .DS_Store
6
+
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ *.py[codz]
10
+ *$py.class
11
+
12
+ # C extensions
13
+ *.so
14
+
15
+ # Distribution / packaging
16
+ .Python
17
+ build/
18
+ develop-eggs/
19
+ dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ share/python-wheels/
30
+ *.egg-info/
31
+ .installed.cfg
32
+ *.egg
33
+ MANIFEST
34
+
35
+ # PyInstaller
36
+ # Usually these files are written by a python script from a template
37
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
38
+ *.manifest
39
+ *.spec
40
+
41
+ # Installer logs
42
+ pip-log.txt
43
+ pip-delete-this-directory.txt
44
+
45
+ # Unit test / coverage reports
46
+ htmlcov/
47
+ .tox/
48
+ .nox/
49
+ .coverage
50
+ .coverage.*
51
+
52
+ # Ignore database files
53
+ *.db
54
+ .cache
55
+ nosetests.xml
56
+ coverage.xml
57
+ *.cover
58
+ *.py.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+ cover/
62
+
63
+ # Translations
64
+ *.mo
65
+ *.pot
66
+
67
+ # Django stuff:
68
+ *.log
69
+ local_settings.py
70
+ db.sqlite3
71
+ db.sqlite3-journal
72
+
73
+ # Flask stuff:
74
+ instance/
75
+ .webassets-cache
76
+
77
+ # Scrapy stuff:
78
+ .scrapy
79
+
80
+ # Sphinx documentation
81
+ docs/_build/
82
+
83
+ # PyBuilder
84
+ .pybuilder/
85
+ target/
86
+
87
+ # Jupyter Notebook
88
+ .ipynb_checkpoints
89
+
90
+ # IPython
91
+ profile_default/
92
+ ipython_config.py
93
+
94
+ # pyenv
95
+ # For a library or package, you might want to ignore these files since the code is
96
+ # intended to run in multiple environments; otherwise, check them in:
97
+ # .python-version
98
+
99
+ # pipenv
100
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
101
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
102
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
103
+ # install all needed dependencies.
104
+ #Pipfile.lock
105
+
106
+ # UV
107
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
108
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
109
+ # commonly ignored for libraries.
110
+ #uv.lock
111
+
112
+ # poetry
113
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
114
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
115
+ # commonly ignored for libraries.
116
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
117
+ #poetry.lock
118
+ #poetry.toml
119
+
120
+ # pdm
121
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
122
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
123
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
124
+ #pdm.lock
125
+ #pdm.toml
126
+ .pdm-python
127
+ .pdm-build/
128
+
129
+ # pixi
130
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
131
+ #pixi.lock
132
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
133
+ # in the .venv directory. It is recommended not to include this directory in version control.
134
+ .pixi
135
+
136
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
137
+ __pypackages__/
138
+
139
+ # Celery stuff
140
+ celerybeat-schedule
141
+ celerybeat.pid
142
+
143
+ # SageMath parsed files
144
+ *.sage.py
145
+
146
+ # Environments
147
+ .env
148
+ .envrc
149
+ .venv
150
+ env/
151
+ venv/
152
+ ENV/
153
+ env.bak/
154
+ venv.bak/
155
+ /bdenv
156
+
157
+ # Spyder project settings
158
+ .spyderproject
159
+ .spyproject
160
+
161
+ # Rope project settings
162
+ .ropeproject
163
+
164
+ # mkdocs documentation
165
+ /site
166
+
167
+ # mypy
168
+ .mypy_cache/
169
+ .dmypy.json
170
+ dmypy.json
171
+
172
+ # Pyre type checker
173
+ .pyre/
174
+
175
+ # pytype static type analyzer
176
+ .pytype/
177
+
178
+ # Cython debug symbols
179
+ cython_debug/
180
+
181
+ # PyCharm
182
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
183
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
184
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
185
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
186
+ #.idea/
187
+
188
+ # Abstra
189
+ # Abstra is an AI-powered process automation framework.
190
+ # Ignore directories containing user credentials, local state, and settings.
191
+ # Learn more at https://abstra.io/docs
192
+ .abstra/
193
+
194
+ # Visual Studio Code
195
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
196
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
197
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
198
+ # you could uncomment the following to ignore the entire vscode folder
199
+ # .vscode/
200
+
201
+ # Ruff stuff:
202
+ .ruff_cache/
203
+
204
+ # PyPI configuration file
205
+ .pypirc
206
+
207
+ # Cursor
208
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
209
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
210
+ # refer to https://docs.cursor.com/context/ignore-files
211
+ .cursorignore
212
+ .cursorindexingignore
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kshitij Nevrekar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,20 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Braindump Sanctuary
3
- emoji: πŸš€
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: ✨ Transform scattered thoughts into insights.
12
- license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- # Welcome to Streamlit!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
1
+ # 🧠 Brain Dump Sanctuary
2
+
3
+ > **Capture, cluster, and reflect on your thoughts in real-time.**
4
+
5
+ A full-stack NLP application that transforms scattered thoughts into organized, semantically meaningful clusters with AI-powered insights. Built with Streamlit, LangGraph agents, and Neo4j.
6
+
7
+ ---
8
+
9
+ ## ✨ Features
10
+
11
+ ### Core Capabilities
12
+ - **πŸ’­ Brain Dump Capture**: Write down thoughts, ideas, and questions instantly
13
+ - **πŸ”— Semantic Clustering**: Automatically group related thoughts using embeddings (sentence-transformers) and HDBSCAN
14
+ - **πŸ“Š Interactive Knowledge Graph**: Visualize all thoughts as an interactive Plotly graph with color-coded clusters
15
+ - **🏷️ AI-Generated Labels**: Auto-label clusters using Google Gemini LLM (e.g., "Personal Growth", "Technical Concepts")
16
+ - **πŸ€– Multi-Agent Intelligence**:
17
+ - **SearchAgent**: Generate contextualized summaries with optional web search (Tavily)
18
+ - **QuestionAgent**: Generate Socratic questions for deeper reflection
19
+ - **GenerationAgent**: Synthesize insights across related thoughts
20
+ - **FeedAgent**: Curate a blog-style feed of your 5 most recent thoughts with agent insights
21
+ - **πŸ“ Persistent Storage**: All thoughts stored in Neo4j graph database with full history
22
+ - **πŸ”„ Real-time Updates**: Refresh embeddings and recalculate clusters on demand
23
+
24
+ ### Two-View Interface
25
+ 1. **Home Tab** 🏠: Semantic cluster map + comprehensive table of all thoughts
26
+ 2. **Feed Tab** πŸ“°: Blog-style cards with summaries and reflection questions
27
+
28
+ ---
29
+
30
+ ## πŸ—οΈ Architecture
31
+
32
+ ### Tech Stack
33
+ ```
34
+ Frontend: Streamlit (single-page Python web app)
35
+ Backend: Python 3.10+ with LangChain/LangGraph
36
+ Database: Neo4j Aura (graph database)
37
+ NLP Pipeline:
38
+ - Embeddings: sentence-transformers (all-MiniLM-L6-v2)
39
+ - Clustering: HDBSCAN + UMAP dimensionality reduction
40
+ - LLM: Google Gemini 2.5 Flash (via LangChain)
41
+ Search: Tavily API (optional, with mock fallback)
42
+ ```
43
+
44
+ ### System Architecture
45
+
46
+ ```
47
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
48
+ β”‚ STREAMLIT UI β”‚
49
+ β”‚ (app.py - Home Tab | Feed Tab | Sidebar Controls) β”‚
50
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
51
+ β”‚
52
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
53
+ β”‚ β”‚
54
+ β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
55
+ β”‚ AGENTS β”‚ β”‚ EMBEDDINGS β”‚
56
+ β”‚ (Day2) β”‚ β”‚ & CLUSTERS β”‚
57
+ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ (Day 1) β”‚
58
+ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
59
+ β”‚ β”‚
60
+ β”Œβ”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”
61
+ β”‚ BRAINDUMP_CORE.PY β”‚
62
+ β”‚ - BrainDumpDB (Neo4j) β”‚
63
+ β”‚ - EmbeddingEngine β”‚
64
+ β”‚ - ClusterEngine β”‚
65
+ β”‚ - ClusterLabelEngine β”‚
66
+ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
67
+ β”‚
68
+ β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
69
+ β”‚ NEO4J GRAPH DB β”‚
70
+ β”‚ (Aura Cloud) β”‚
71
+ β”‚ - Dump Nodes β”‚
72
+ β”‚ - Cluster Nodes β”‚
73
+ β”‚ - Relationships β”‚
74
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
75
+ ```
76
+
77
+ ### Data Flow
78
+
79
+ **Input β†’ Processing β†’ Storage β†’ Visualization**
80
+
81
+ 1. **User enters brain dump** β†’ `app.py` sidebar
82
+ 2. **Generate embedding** β†’ `EmbeddingEngine` (sentence-transformer)
83
+ 3. **Store in Neo4j** β†’ `BrainDumpDB.add_dump()`
84
+ 4. **Re-cluster on demand** β†’ `ClusterEngine.cluster()` + `ClusterLabelEngine.label_clusters()`
85
+ 5. **Visualize clusters** β†’ Plotly interactive graph (Home tab)
86
+ 6. **Enrich with AI** β†’ Agents analyze for Feed tab
87
+
88
+ ### Neo4j Graph Schema
89
+
90
+ ```cypher
91
+ Node: Dump
92
+ β”œβ”€β”€ id: UUID
93
+ β”œβ”€β”€ text: String (the brain dump)
94
+ β”œβ”€β”€ embedding: Vector[384] (from sentence-transformer)
95
+ β”œβ”€β”€ created_at: DateTime
96
+ └── cluster_id: Reference to Cluster
97
+
98
+ Node: Cluster
99
+ β”œβ”€β”€ id: UUID
100
+ β”œβ”€β”€ label: String (e.g., "Personal Growth")
101
+ β”œβ”€β”€ description: String (optional)
102
+ β”œβ”€β”€ created_at: DateTime
103
+
104
+ Relationships:
105
+ β”œβ”€β”€ Dump -[:IN_CLUSTER]-> Cluster
106
+ └── Dump -[:SIMILAR_TO {weight: 0.0-1.0}]-> Dump
107
+ ```
108
+
109
+ ### Component Breakdown
110
+
111
+ | File | Purpose | Key Classes |
112
+ |------|---------|------------|
113
+ | `app.py` | Streamlit UI & session management | Main app logic, page layout |
114
+ | `braindump_core.py` | NLP pipeline & database | `BrainDumpDB`, `EmbeddingEngine`, `ClusterEngine`, `ClusterLabelEngine` |
115
+ | `agents.py` | AI agents for insights | `QuestionAgent`, `SearchAgent`, `GenerationAgent`, `FeedAgent` |
116
+ | `neo4j_maintenance.py` | Database utilities | Neo4j query helpers, debugging |
117
+ | `cleanup_db.py` | Reset database | Wipe all data (for testing) |
118
+
119
+ ---
120
+
121
+ ## πŸš€ Quick Start
122
+
123
+ ### Prerequisites
124
+ - Python 3.10+
125
+ - Neo4j Aura account (free tier available)
126
+ - API keys for Google Gemini (free) and optionally Tavily
127
+
128
+ ### 1. Setup Neo4j (Required)
129
+
130
+ Create a free Neo4j Aura instance:
131
+ 1. Go to https://console.neo4j.io/
132
+ 2. Sign up for a free account
133
+ 3. Create a new "Free" Aura instance
134
+ 4. Copy your connection details:
135
+ - **URI**: `neo4j+ssc://xxxxx.databases.neo4j.io`
136
+ - **Username**: `neo4j`
137
+ - **Password**: (set during creation)
138
+
139
+ ### 2. Installation
140
+
141
+ ```bash
142
+ # Clone repository
143
+ git clone <repo-url>
144
+ cd braindump-sanctuary
145
+
146
+ # Install dependencies
147
+ pip install -r requirements.txt
148
+
149
+ # Create environment file
150
+ cp .env.example .env # or create manually
151
+
152
+ # Edit .env with your credentials
153
+ ```
154
+
155
+ **.env Template:**
156
+ ```env
157
+ # Neo4j (Required)
158
+ NEO4J_URI=neo4j+ssc://your-instance-id.databases.neo4j.io
159
+ NEO4J_USER=neo4j
160
+ NEO4J_PASSWORD=your-password-here
161
+
162
+ # Google Gemini (Required for AI features)
163
+ GOOGLE_API_KEY=your_google_api_key_here
164
+
165
+ # Web Search (Optional - uses mock data if not provided)
166
+ TAVILY_API_KEY=your_tavily_api_key_here
167
+
168
+ # Alternative: Perplexity (Optional)
169
+ PERPLEXITY_API_KEY=your_perplexity_key_here
170
+ ```
171
+
172
+ **Get API Keys:**
173
+ - πŸ”‘ **Google Gemini** (free): https://aistudio.google.com/app/apikey
174
+ - πŸ” **Tavily Search** (optional): https://app.tavily.com
175
+ - 🧠 **Perplexity** (optional): https://www.perplexity.ai/
176
+
177
+ ### 3. Run the App
178
+
179
+ ```bash
180
+ streamlit run app.py
181
+ ```
182
+
183
+ App opens at `http://localhost:8501`
184
+
185
+ ---
186
+
187
+ ## πŸ“± User Interface Guide
188
+
189
+ ### Sidebar Controls
190
+
191
+ **Left Sidebar** - Main interaction hub:
192
+ - πŸ—ΊοΈ **Navigation**: Radio buttons to toggle between **Home** and **Feed** tabs
193
+ - πŸ“ **Text Area**: Input field for new brain dumps
194
+ - βœ… **Add to Sanctuary**: Save the thought to Neo4j
195
+ - πŸ—‘οΈ **Clear**: Empty the input field
196
+ - πŸ”„ **Refresh Clusters**: Recalculate all embeddings and clusters (computationally intensive, 10-30 seconds)
197
+ - ⚠️ **Clear All Dumps**: Permanently delete all thoughts from the database
198
+ - πŸ“Š **Total Brain Dumps**: Counter showing total stored thoughts
199
+
200
+ ### Home Tab 🏠 - Knowledge Graph View
201
+
202
+ **Top Section: Interactive Cluster Map**
203
+ - **Visualization**: 2D interactive Plotly graph
204
+ - **Nodes**: Each point = one brain dump
205
+ - **Colors**: Each color = a semantic cluster (similar ideas grouped together)
206
+ - **Layout**: UMAP dimensionality reduction for spatial meaning
207
+ - **Interactions**: Hover to see full text, zoom/pan to explore
208
+ - **Relationships**: Edges show semantic similarity between thoughts
209
+
210
+ **Bottom Section: Comprehensive Table**
211
+ - **Columns**: Brain Dump | Cluster Label | Created At
212
+ - **Sorting**: Most recent first (newest at top)
213
+ - **Format**: Plain text, easy to copy
214
+
215
+ **Actions**:
216
+ - Click "πŸ”„ **Refresh Clusters**" to recalculate (when you add many new thoughts)
217
+ - Spinner animations show progress of embedding generation and labeling
218
+
219
+ ### Feed Tab πŸ“° - Blog-Style Feed
220
+
221
+ **What You See**:
222
+ 1. **Up to 5 Recent Cards** in reverse chronological order
223
+ 2. **Each Card Contains**:
224
+ - πŸ’­ **Title**: Your full brain dump text
225
+ - 🏷️ **Cluster Badge**: Category label (generated by LLM)
226
+ - πŸ“„ **Summary**: AI-generated context
227
+ - If Tavily API set: Real web search + synthesis
228
+ - If not: Mock contextual data (demo mode)
229
+ - ❓ **Reflection Questions**: 5 Socratic questions (from QuestionAgent)
230
+
231
+ **Why Use Feed?**
232
+ - Quick morning review without table scrolling
233
+ - AI-generated insights help you think deeper
234
+ - Guided reflection via questions
235
+ - Patterns become visible across multiple cards
236
+ - Perfect for journaling workflow
237
+
238
+ ---
239
+
240
+ ## πŸ”„ Typical Workflows
241
+
242
+ ### Workflow 1: Daily Brain Dumping + Review
243
+ ```
244
+ Morning:
245
+ 1. Open app at http://localhost:8501
246
+ 2. Sidebar: Type 3-5 quick thoughts
247
+ 3. Click "Add to Sanctuary" each time
248
+ 4. Switch to Feed tab
249
+ 5. Read summaries and reflect on questions
250
+ 6. (Takes 5-10 minutes)
251
+
252
+ Evening:
253
+ 1. Go to Home tab
254
+ 2. Click "Refresh Clusters"
255
+ 3. Observe how new thoughts clustered with existing ones
256
+ 4. Spot emerging themes and connections
257
+ ```
258
+
259
+ ### Workflow 2: Deep Dive Analysis
260
+ ```
261
+ 1. Accumulate 20+ thoughts over several days
262
+ 2. Go to Home tab
263
+ 3. Click "Refresh Clusters"
264
+ 4. Examine the visualization:
265
+ - Which clusters are densest?
266
+ - What themes emerge?
267
+ - Any surprising connections?
268
+ 5. Switch to Feed tab
269
+ 6. Read the agent-generated insights
270
+ 7. Use for next creative session
271
+ ```
272
+
273
+ ### Workflow 3: Topic-Specific Exploration
274
+ ```
275
+ 1. Add 10+ thoughts about a specific topic
276
+ 2. They should cluster together automatically
277
+ 3. Hover over the cluster in Home tab
278
+ 4. Read the AI-generated cluster label
279
+ 5. Check Feed tab for synthesis across related thoughts
280
+ 6. Use the questions to drill deeper
281
+ ```
282
+
283
+ ---
284
+
285
+ ## πŸ€– Agent Capabilities
286
+
287
+ ### QuestionAgent
288
+ **Role**: Socratic questioning for deeper reflection
289
+
290
+ **Input**: Brain dump text
291
+ **Output**: 5 open-ended questions
292
+
293
+ **Example**:
294
+ ```
295
+ Brain Dump: "Why do I procrastinate on important tasks?"
296
+
297
+ Generated Questions:
298
+ 1. What task are you procrastinating on right now?
299
+ 2. What emotion arises when you think about starting it?
300
+ 3. What would you do instead if you didn't procrastinate?
301
+ 4. What is the smallest first step you could take?
302
+ 5. What would happen if you started today?
303
+ ```
304
+
305
+ ### SearchAgent
306
+ **Role**: Web search + synthesis or mock data generation
307
+
308
+ **Input**: Brain dump text
309
+ **Output**: Relevant context or summary
310
+
311
+ **With Tavily API**:
312
+ - Searches the web for related information
313
+ - Synthesizes findings with LLM
314
+ - Provides citations and context
315
+
316
+ **Without Tavily API** (Demo Mode):
317
+ - Generates plausible contextual information
318
+ - Maintains consistency with the thought
319
+ - Allows testing without API key
320
+
321
+ ### GenerationAgent
322
+ **Role**: Cross-thought synthesis
323
+
324
+ **Input**: Multiple related brain dumps (from same cluster)
325
+ **Output**: Synthesized insight combining themes
326
+
327
+ **Example**:
328
+ ```
329
+ Related Dumps:
330
+ - "Neural networks learn patterns"
331
+ - "Our brains also learn from patterns"
332
+ - "What if consciousness is pattern recognition?"
333
+
334
+ Synthesis: These thoughts suggest that consciousness might emerge
335
+ from the brain's pattern recognition capabilities...
336
+ ```
337
+
338
+ ### FeedAgent
339
+ **Role**: Orchestrates other agents for feed generation
340
+
341
+ **Process**:
342
+ 1. Selects 5 most recent thoughts
343
+ 2. For each thought:
344
+ - Gets cluster assignment
345
+ - Calls SearchAgent for summary
346
+ - Calls QuestionAgent for reflection questions
347
+ 3. Formats as blog-style cards
348
+
349
+ ---
350
+
351
+ ## βš™οΈ Configuration & Customization
352
+
353
+ ### Environment Variables (.env)
354
+
355
+ Create `.env` file in project root:
356
+
357
+ ```bash
358
+ # ============ REQUIRED ============
359
+
360
+ # Neo4j Aura Connection
361
+ NEO4J_URI=neo4j+ssc://your-instance-id.databases.neo4j.io
362
+ NEO4J_USER=neo4j
363
+ NEO4J_PASSWORD=your-super-secure-password
364
+
365
+ # Google Gemini LLM API
366
+ GOOGLE_API_KEY=AIzaSyDxxxxxxxxxxxxxxxxxxxxx
367
+
368
+ # ============ OPTIONAL ============
369
+
370
+ # Tavily Web Search (for better summaries)
371
+ TAVILY_API_KEY=tvly-xxxxxxxxxxxxxxxxx
372
+
373
+ # Perplexity API (alternative search)
374
+ PERPLEXITY_API_KEY=pplx-xxxxxxxxxxxxxxxxx
375
+ ```
376
+
377
+ ### Optional: Streamlit Configuration
378
+
379
+ Create `.streamlit/config.toml`:
380
+
381
+ ```toml
382
+ [theme]
383
+ primaryColor = "#4ECDC4"
384
+ backgroundColor = "#0d1117"
385
+ secondaryBackgroundColor = "#161b22"
386
+ textColor = "#c9d1d9"
387
+
388
+ [client]
389
+ showErrorDetails = true
390
+
391
+ [logger]
392
+ level = "info"
393
+
394
+ [server]
395
+ port = 8501
396
+ ```
397
+
398
+ ---
399
+
400
+ ## πŸ”§ Development & Debugging
401
+
402
+ ### Running Locally
403
+ ```bash
404
+ streamlit run app.py
405
+ ```
406
+
407
+ ### Clearing Database
408
+ ```bash
409
+ python cleanup_db.py
410
+ ```
411
+
412
+ ### Neo4j Maintenance
413
+ ```bash
414
+ python neo4j_maintenance.py
415
+ ```
416
+
417
+ ### Viewing Logs
418
+ ```bash
419
+ # Terminal shows Streamlit logs
420
+ # Check sidebar for spinner status during refresh
421
+ ```
422
+
423
+ ### Common Issues
424
+
425
+ **Issue**: "Failed to connect to Neo4j"
426
+ - Verify `.env` has correct URI, username, password
427
+ - Check Neo4j Aura instance is running (console.neo4j.io)
428
+ - Firewall: Neo4j needs outbound HTTPS (port 7687)
429
+
430
+ **Issue**: "GOOGLE_API_KEY not found"
431
+ - Ensure key is in `.env` file
432
+ - Restart streamlit: `streamlit run app.py`
433
+
434
+ **Issue**: Clusters not showing
435
+ - Click "Refresh Clusters" in sidebar
436
+ - Wait for spinner to finish (10-30 seconds)
437
+ - Needs at least 2-3 thoughts for clustering
438
+
439
+ **Issue**: Tavily search not working
440
+ - Mock data is used if API key missing (expected)
441
+ - Optional feature; not required for core functionality
442
+
443
+ ---
444
+
445
+ ## πŸ“Š Performance Notes
446
+
447
+ ### Computational Complexity
448
+ - **Adding 1 thought**: ~1 second
449
+ - **Clustering N thoughts**: ~O(N) to O(N log N) depending on N
450
+ - 10 thoughts: ~2 seconds
451
+ - 100 thoughts: ~5-10 seconds
452
+ - 1000 thoughts: ~30-60 seconds
453
+ - **LLM labeling**: ~2-5 seconds per cluster (depends on Gemini API latency)
454
+
455
+ ### Storage
456
+ - Neo4j Free Tier: ~5 GB storage
457
+ - Typical thought: ~500 bytes
458
+ - Can store ~10 million thoughts theoretically
459
+
460
+ ### Scaling Recommendations
461
+ - **Local testing**: βœ… Recommended
462
+ - **Shared team use**: Consider dedicated Neo4j instance
463
+ - **Large scale (10k+ thoughts)**: May need performance tuning (indexing, batching)
464
+
465
+ ---
466
+
467
+ ## πŸŽ“ Learning Resources
468
+
469
+ ### Concepts Explained
470
+
471
+ **Semantic Embeddings**
472
+ - sentence-transformers model converts text to 384-dimensional vectors
473
+ - Similar texts β†’ similar vectors β†’ can cluster together
474
+ - Distance in vector space β‰ˆ semantic similarity
475
+
476
+ **HDBSCAN Clustering**
477
+ - Density-based clustering (unlike K-means which needs K)
478
+ - Automatically finds clusters of any shape
479
+ - Robust to outliers (marks them as "noise")
480
+
481
+ **Neo4j Graph Database**
482
+ - Stores relationships as first-class citizens
483
+ - Fast for relationship queries (unlike SQL)
484
+ - Perfect for knowledge graphs and recommendations
485
+
486
+ **LLMs for Labeling**
487
+ - Google Gemini generates cluster labels from examples
488
+ - LangChain chains handle prompt + LLM + parsing
489
+ - Enables semantic understanding of cluster themes
490
+
491
+ ---
492
+
493
+ ## πŸ“ License
494
+
495
+ See `LICENSE` file for details.
496
+
497
  ---
498
+
499
+ ## 🀝 Contributing
500
+
501
+ Found a bug or have a feature idea?
502
+
503
+ 1. Check existing issues
504
+ 2. Create new issue with clear description
505
+ 3. (Optional) Submit PR with fix
506
+
507
+ ---
508
+
509
+ ## ❓ FAQ
510
+
511
+ **Q**: Can I use this with a local Neo4j instance?
512
+ **A**: Yes! Update `NEO4J_URI` to `neo4j://localhost:7687` in `.env`
513
+
514
+ **Q**: What if I don't have a Tavily API key?
515
+ **A**: That's fine! SearchAgent will generate mock data (demo mode)
516
+
517
+ **Q**: Can I export all my thoughts?
518
+ **A**: Use the table in Home tab or query Neo4j directly. Export feature coming soon.
519
+
520
+ **Q**: How often should I click "Refresh Clusters"?
521
+ **A**: After adding multiple new thoughts (5+), or when you want to see updated organization.
522
+
523
+ **Q**: Is my data secure?
524
+ **A**: Only you have your Neo4j password. Data is encrypted in transit and at rest on Neo4j Aura.
525
+
526
  ---
527
 
528
+ ## πŸš€ Roadmap
529
+
530
+ - [ ] Export functionality (CSV, JSON, markdown)
531
+ - [ ] Thought search & filtering
532
+ - [ ] Collaborative mode (multiple users)
533
+ - [ ] Email digest of weekly summaries
534
+ - [ ] Browser extension for quick capture
535
+ - [ ] Mobile app
536
+ - [ ] Advanced analytics dashboard
537
+
538
+ [client]
539
+ showErrorDetails = true
540
+ ```
541
+
542
+ ---
543
+
544
+ ## πŸ”§ Database Management
545
+
546
+ ### Re-calculate Clusters
547
+ 1. Go to Home tab
548
+ 2. Click πŸ”„ **Refresh** button
549
+ 3. Wait for spinner to complete
550
+
551
+ ### Clear Everything and Start Fresh
552
+ 1. Click πŸ—‘οΈ **Clear All Dumps** in sidebar
553
+ 2. Confirm action
554
+ 3. Add new thoughts
555
+
556
+ ### Neo4j Maintenance
557
+ Use the `neo4j_maintenance.py` script:
558
+ ```bash
559
+ # Show database statistics
560
+ python neo4j_maintenance.py stats
561
+
562
+ # Remove duplicate brain dumps
563
+ python neo4j_maintenance.py dedup
564
+
565
+ # Clear all data (WARNING: Irreversible)
566
+ python neo4j_maintenance.py clear
567
+ ```
568
+
569
+ ---
570
+
571
+ ## πŸ“Š Performance Tips
572
+
573
+ ### Clustering
574
+ - **Fast**: 3-20 dumps (< 5 seconds)
575
+ - **Moderate**: 20-100 dumps (5-30 seconds)
576
+ - **Slow**: 100+ dumps (> 30 seconds)
577
+ - Use caching to avoid recomputing
578
+
579
+ ### API Usage
580
+ - **Google Gemini**: Free tier allows ~60 requests/min
581
+ - **Tavily Search**: Free tier allows ~100 searches/month
582
+ - Set environment variables to enable features
583
+
584
+ ### Neo4j Query Performance
585
+ - Dump and cluster lookups are indexed for fast retrieval
586
+ - Embedding vectors cached in graph nodes
587
+ - Knowledge graph relationships enable fast similarity searches
588
+
589
+ ---
590
+
591
+ ## πŸ› Troubleshooting
592
+
593
+ ### Neo4j Connection Error
594
+ β†’ Verify your Aura instance is running: https://console.neo4j.io/
595
+ β†’ Check `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` in `.env`
596
+ β†’ Ensure your IP is allowlisted in Aura instance settings
597
+
598
+ ### "API Key not found" error
599
+ β†’ Make sure `.env` file exists with `GOOGLE_API_KEY`
600
+ β†’ Restart streamlit: `streamlit run app.py`
601
+
602
+ ### Clustering takes forever
603
+ β†’ You might have 100+ dumps
604
+ β†’ Only happens on first run or "Refresh"
605
+ β†’ Consider batching cluster operations for large datasets
606
+
607
+ ### Feed cards showing mock data
608
+ β†’ Your `TAVILY_API_KEY` isn't set
609
+ β†’ Add it to `.env` and restart
610
+
611
+ ### "ModuleNotFoundError" for imports
612
+ β†’ Missing dependencies: `pip install -r requirements.txt`
613
+ β†’ Wrong Python version? Try `python3 -m pip install ...`
614
+
615
+ ---
616
+
617
+ ## πŸ“š Architecture Overview
618
+
619
+ ```
620
+ braindump_core.py
621
+ β”œβ”€β”€ BrainDumpDB (Neo4j Graph Database)
622
+ β”œβ”€β”€ EmbeddingEngine (sentence-transformers)
623
+ β”œβ”€β”€ ClusterEngine (HDBSCAN + UMAP)
624
+ └── Visualization (Plotly)
625
+
626
+ agents.py
627
+ β”œβ”€β”€ QuestionAgent (Socratic questions)
628
+ └── SearchAgent (Web search + synthesis)
629
+
630
+ app.py
631
+ β”œβ”€β”€ Sidebar (Input + Navigation)
632
+ β”œβ”€β”€ Home Tab (Knowledge Graph + Table)
633
+ β”œβ”€β”€ Feed Tab (Blog-style cards)
634
+ └── Helper Functions (Rendering)
635
+
636
+ neo4j_maintenance.py
637
+ β”œβ”€β”€ Database statistics
638
+ β”œβ”€β”€ Deduplication
639
+ └── Data cleanup
640
+ ```
641
+
642
+ ---
643
+
644
+ ## 🎯 Next Steps
645
+
646
+ 1. **Set up Neo4j Aura** instance at https://console.neo4j.io/
647
+ 2. **Add your first thought** via sidebar
648
+ 3. **Switch between Home and Feed** to see different views
649
+ 4. **Check back daily** to build your idea garden
650
+ 5. **Monitor performance** using `neo4j_maintenance.py stats`
651
+
652
+ ---
653
+
654
+ ## πŸ’‘ Tips
655
+
656
+ - **Best for**: Capturing fleeting thoughts, finding patterns, exploring ideas
657
+ - **Start small**: 5-10 thoughts to see clustering in action
658
+ - **Use specificity**: "Why do dreams fade?" works better than "dreams"
659
+ - **Review regularly**: Feed tab is great for morning/evening review
660
+ - **Share clusters**: Screenshot Home tab to share your thinking
661
+
662
+ ---
663
+
664
+ ## πŸ“ Notes
665
+
666
+ - All data stored locally in `braindump.db` (no cloud sync)
667
+ - Embeddings cached in database (fast retrieval)
668
+ - Cluster labels generated once and reused
669
+ - Each brain dump timestamped automatically
670
+
671
+ ---
672
 
673
+ **Ready to catch your thoughts before they slip away!** 🧠✨
674
 
675
+ For issues or questions, check REFACTOR_SUMMARY.md or ARCHITECTURE.md
 
agents.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents.py (Day 2)
3
+
4
+ Contains the agentic logic for the Brain Dump Sanctuary.
5
+ This file provides the classes that app.py imports.
6
+
7
+ - QuestionAgent: Socratic question generation
8
+ - PerspectiveAgent: Multi-angle analysis
9
+ - SearchAgent: Web search + synthesis (MOCKED for Day 2)
10
+
11
+ This file uses simple, direct LLM calls for the Day 2 demo.
12
+ It is designed to be plug-and-play with app.py.
13
+
14
+ Future Work: These classes can be refactored into nodes
15
+ in a more complex LangGraph orchestration graph.
16
+ """
17
+
18
+ import os
19
+ import json
20
+ import requests
21
+
22
+ # We'll use Google Gemini as the LLM
23
+ import google.generativeai as genai
24
+
25
+ from dotenv import load_dotenv
26
+
27
+ # Tavily for web search
28
+ from tavily import TavilyClient
29
+
30
+ # --- API Key Configuration ---
31
+ # Add your "GOOGLE_API_KEY" to Kaggle secrets or environment variables.
32
+ # Get your free key from: https://aistudio.google.com/app/apikey
33
+ try:
34
+ load_dotenv()
35
+ # Fallback for local dev or other environments
36
+ API_KEY = os.getenv("GOOGLE_API_KEY")
37
+ except Exception:
38
+ API_KEY = None
39
+
40
+ if not API_KEY:
41
+ print("WARNING: GOOGLE_API_KEY not found. Please set it in your environment or Kaggle secrets.")
42
+ print("Get your free API key from: https://aistudio.google.com/app/apikey")
43
+ # Set a placeholder to avoid crashing, but calls will fail.
44
+ API_KEY = "YOUR_API_KEY_HERE"
45
+ else:
46
+ # Configure Gemini with the API key
47
+ genai.configure(api_key=API_KEY)
48
+
49
+ # --- Tavily API Key Configuration ---
50
+ TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
51
+ if not TAVILY_API_KEY:
52
+ print("WARNING: TAVILY_API_KEY not found. SearchAgent will use mock data.")
53
+ print("Get your API key from: https://app.tavily.com")
54
+ TAVILY_API_KEY = None
55
+
56
+ # --- Perplexity API Key Configuration ---
57
+ PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
58
+ if not PERPLEXITY_API_KEY:
59
+ print("ℹ️ PERPLEXITY_API_KEY not found. Will use Tavily or mock data.")
60
+ print("Get your API key from: https://www.perplexity.ai/")
61
+ PERPLEXITY_API_KEY = None
62
+
63
+
64
+ # ============== 1. Socratic Question Agent ==============
65
+
66
+ class QuestionAgent:
67
+ """
68
+ Generates Socratic questions to expand on a vague idea.
69
+ """
70
+ def __init__(self, model="gemini-2.5-flash"):
71
+ self.model = model
72
+ self.system_prompt = """
73
+ You are a Socratic tutor. A user has a vague brain dump idea.
74
+ Your goal is to generate 5 insightful, open-ended questions to help them
75
+ explore this idea, discover its core, and understand their own curiosity.
76
+
77
+ - Do not answer the questions.
78
+ - Provide ONLY the list of questions.
79
+ - Return the questions as a JSON list of strings.
80
+
81
+ Example:
82
+ User Idea: "Why do we forget things we just read?"
83
+ Your Response:
84
+ [
85
+ "What kind of material do you find you forget most often?",
86
+ "What is your state of mind when you are reading?",
87
+ "Are you trying to memorize facts, or understand a concept?",
88
+ "What is the difference between remembering a fact and understanding an idea?",
89
+ "How does this relate to the 'forgetting curve'?"
90
+ ]
91
+ """
92
+
93
+ def generate_questions(self, idea: str) -> list[str]:
94
+ """Generates Socratic questions for a given idea."""
95
+ if API_KEY == "YOUR_API_KEY_HERE":
96
+ return ["Error: GOOGLE_API_KEY is not set.", "Please add it to your environment or Kaggle secrets."]
97
+
98
+ try:
99
+ # Create Gemini model
100
+ model = genai.GenerativeModel(
101
+ model_name=self.model,
102
+ generation_config={
103
+ "temperature": 0.7,
104
+ "response_mime_type": "application/json"
105
+ }
106
+ )
107
+
108
+ # Combine system prompt and user input
109
+ prompt = f"{self.system_prompt}\n\nUser Idea: \"{idea}\""
110
+
111
+ # Generate response
112
+ response = model.generate_content(prompt)
113
+ questions_json_string = response.text
114
+ questions = json.loads(questions_json_string)
115
+
116
+ # The LLM might return a dict {"questions": [...]}, or just [...]
117
+ if isinstance(questions, dict):
118
+ # Try to find the list value
119
+ for key, value in questions.items():
120
+ if isinstance(value, list):
121
+ return value
122
+ elif isinstance(questions, list):
123
+ return questions
124
+
125
+ return ["Error: Could not parse questions from LLM response."]
126
+
127
+ except Exception as e:
128
+ print(f"Error in QuestionAgent: {e}")
129
+ return [
130
+ "An error occurred while generating questions.",
131
+ "Is your API key set correctly?",
132
+ f"Details: {e}"
133
+ ]
134
+
135
+
136
+ # ============== 2. Multi-Perspective Agent ==============
137
+
138
+ class PerspectiveAgent:
139
+ """
140
+ Analyzes a controversial topic from multiple angles.
141
+ Can optionally use web search results for context.
142
+ """
143
+ def __init__(self, model="gemini-2.5-flash", search_agent=None):
144
+ self.model = model
145
+ self.search_agent = search_agent
146
+ self.system_prompt = """
147
+ You are a multi-perspective analyst. A user has a topic,
148
+ which may be controversial or nuanced.
149
+ Your goal is to provide three distinct, concise viewpoints:
150
+ 1. **Skeptical View:** The critical or cautious perspective.
151
+ 2. **Optimistic View:** The positive or enthusiastic perspective.
152
+ 3. **Nuanced View:** A balanced, synthetic view that includes trade-offs or a "third way".
153
+
154
+ You MUST return a JSON object with exactly three keys:
155
+ "skeptical", "optimistic", and "nuanced".
156
+ Each value should be a short paragraph (2-4 sentences).
157
+ """
158
+
159
+ def analyze(self, topic: str) -> dict[str, str]:
160
+ """Analyzes a topic from multiple perspectives with optional web context."""
161
+ if API_KEY == "YOUR_API_KEY_HERE":
162
+ return {
163
+ "skeptical": "Error: GOOGLE_API_KEY not set.",
164
+ "optimistic": "Please add your API key to environment or Kaggle secrets.",
165
+ "nuanced": "The agent cannot run without an API key."
166
+ }
167
+
168
+ try:
169
+ # Get web context if search_agent is available
170
+ web_context = ""
171
+ sources = []
172
+ if self.search_agent and self.search_agent.use_real_search:
173
+ print(f"🌐 Fetching web context for multi-perspective analysis...")
174
+ articles = self.search_agent.get_relevant_articles(topic)
175
+ if articles:
176
+ web_context = "\n\nRelevant Web Context:\n"
177
+ for i, article in enumerate(articles, 1):
178
+ web_context += f"{i}. {article['title']}: {article['snippet']}\n"
179
+ sources.append(article)
180
+
181
+ # Create Gemini model
182
+ model = genai.GenerativeModel(
183
+ model_name=self.model,
184
+ generation_config={
185
+ "temperature": 0.7,
186
+ "response_mime_type": "application/json"
187
+ }
188
+ )
189
+
190
+ # Combine system prompt and user input with optional web context
191
+ prompt = f"{self.system_prompt}\n\nTopic: \"{topic}\"{web_context}"
192
+
193
+ # Generate response
194
+ response = model.generate_content(prompt)
195
+ perspectives_json_string = response.text
196
+ perspectives = json.loads(perspectives_json_string)
197
+
198
+ # Ensure the keys are always present to prevent errors in app.py
199
+ perspectives.setdefault('skeptical', 'No skeptical view generated.')
200
+ perspectives.setdefault('optimistic', 'No optimistic view generated.')
201
+ perspectives.setdefault('nuanced', 'No nuanced view generated.')
202
+
203
+ # Add sources if available
204
+ if sources:
205
+ perspectives['sources'] = sources
206
+
207
+ return perspectives
208
+
209
+ except Exception as e:
210
+ print(f"Error in PerspectiveAgent: {e}")
211
+ return {
212
+ "skeptical": f"An error occurred: {e}",
213
+ "optimistic": "Please check your API key and model access.",
214
+ "nuanced": "The PerspectiveAgent failed to run."
215
+ }
216
+
217
+
218
+ # ============== 3. Web Search Agent ==============
219
+
220
+ class SearchAgent:
221
+ """
222
+ Real web search using Perplexity Sonar (preferred) or Tavily API + Gemini synthesis.
223
+ Falls back to mock data if API keys are missing.
224
+ """
225
+ def __init__(self):
226
+ self.use_perplexity = bool(PERPLEXITY_API_KEY)
227
+ self.use_tavily = bool(TAVILY_API_KEY) and not self.use_perplexity
228
+ self.use_real_search = self.use_perplexity or self.use_tavily
229
+
230
+ if self.use_perplexity:
231
+ self.perplexity_api_key = PERPLEXITY_API_KEY
232
+ print("βœ… Initialized SearchAgent with Perplexity Sonar API")
233
+ elif self.use_tavily:
234
+ self.tavily = TavilyClient(api_key=TAVILY_API_KEY)
235
+ self.llm = genai.GenerativeModel("gemini-2.5-flash")
236
+ print("βœ… Initialized SearchAgent with Tavily API")
237
+ else:
238
+ print("⚠️ Initialized MOCK SearchAgent (no Perplexity or Tavily key)")
239
+
240
+ def deep_dive(self, topic: str) -> dict:
241
+ """
242
+ Searches web using Perplexity Sonar or Tavily, then synthesizes.
243
+ Falls back to mock if no API key.
244
+ """
245
+ if not self.use_real_search:
246
+ return self._mock_search(topic)
247
+
248
+ if self.use_perplexity:
249
+ return self._perplexity_search(topic)
250
+ else:
251
+ return self._tavily_search(topic)
252
+
253
+ def _perplexity_search(self, topic: str) -> dict:
254
+ """Search using Perplexity Sonar API"""
255
+ try:
256
+ print(f"πŸ” Searching Perplexity Sonar for: {topic}")
257
+
258
+ url = "https://api.perplexity.ai/chat/completions"
259
+ headers = {
260
+ "Authorization": f"Bearer {self.perplexity_api_key}",
261
+ "Content-Type": "application/json"
262
+ }
263
+
264
+ payload = {
265
+ "model": "sonar",
266
+ "messages": [
267
+ {
268
+ "role": "system",
269
+ "content": "You are a helpful research assistant. Provide comprehensive, well-sourced answers."
270
+ },
271
+ {
272
+ "role": "user",
273
+ "content": f"Please provide a comprehensive summary about: {topic}"
274
+ }
275
+ ],
276
+ "max_tokens": 1000,
277
+ "temperature": 0.7,
278
+ "top_p": 0.9
279
+ }
280
+
281
+ response = requests.post(url, headers=headers, json=payload)
282
+ response.raise_for_status()
283
+
284
+ data = response.json()
285
+ summary = data.get('choices', [{}])[0].get('message', {}).get('content', 'No summary generated')
286
+
287
+ # Extract citations if available
288
+ sources = []
289
+ if 'citations' in data:
290
+ sources = [{"title": f"Source {i+1}", "url": "#", "snippet": cite}
291
+ for i, cite in enumerate(data['citations'][:5])]
292
+
293
+ return {
294
+ "summary": summary,
295
+ "sources": sources
296
+ }
297
+
298
+ except Exception as e:
299
+ print(f"❌ Perplexity search failed: {e}")
300
+ return {
301
+ "summary": f"Search failed: {str(e)}. Please check your Perplexity API key.",
302
+ "sources": []
303
+ }
304
+
305
+ def _tavily_search(self, topic: str) -> dict:
306
+ """
307
+ Searches web using Tavily, then synthesizes with Gemini.
308
+ """
309
+ try:
310
+ # Step 1: Search with Tavily
311
+ print(f"πŸ” Searching Tavily for: {topic}")
312
+ search_results = self.tavily.search(
313
+ query=topic,
314
+ max_results=5,
315
+ search_depth="advanced"
316
+ )
317
+
318
+ # Step 2: Extract sources
319
+ sources = []
320
+ context = ""
321
+ for result in search_results.get('results', []):
322
+ sources.append({
323
+ 'title': result.get('title', 'Untitled'),
324
+ 'url': result.get('url', '#'),
325
+ 'snippet': result.get('content', '')[:200] + "..."
326
+ })
327
+ context += f"\n\n{result.get('content', '')}"
328
+
329
+ # Step 3: Synthesize with Gemini
330
+ print(f"πŸ€– Synthesizing with Gemini...")
331
+ synthesis_prompt = f"""You are a research synthesizer. Based on the following web search results about "{topic}",
332
+ write a comprehensive 3-4 paragraph summary that:
333
+ 1. Answers the core question
334
+ 2. Highlights key findings and debates
335
+ 3. Cites different viewpoints if applicable
336
+
337
+ Web Search Results:
338
+ {context[:4000]}
339
+
340
+ Provide ONLY the summary text, no preamble."""
341
+
342
+ response = self.llm.generate_content(synthesis_prompt)
343
+ summary = response.text
344
+
345
+ return {
346
+ "summary": summary,
347
+ "sources": sources
348
+ }
349
+
350
+ except Exception as e:
351
+ print(f"❌ Tavily search failed: {e}")
352
+ return {
353
+ "summary": f"Search failed: {str(e)}. Please check your Tavily API key.",
354
+ "sources": []
355
+ }
356
+
357
+ def get_relevant_articles(self, topic: str, max_results: int = 3) -> list[dict]:
358
+ """
359
+ Gets top articles for a topic (used by PerspectiveAgent).
360
+ Returns: [{'title': str, 'url': str, 'snippet': str}, ...]
361
+ """
362
+ if not self.use_real_search:
363
+ return []
364
+
365
+ try:
366
+ if self.use_perplexity:
367
+ print(f"πŸ“° Fetching articles with Perplexity for: {topic}")
368
+ # Perplexity doesn't have a dedicated article fetch, use deep_dive results
369
+ result = self._perplexity_search(topic)
370
+ return result.get('sources', [])[:max_results]
371
+ else:
372
+ print(f"πŸ“° Fetching articles for: {topic}")
373
+ search_results = self.tavily.search(
374
+ query=topic,
375
+ max_results=max_results,
376
+ search_depth="basic"
377
+ )
378
+
379
+ articles = []
380
+ for result in search_results.get('results', []):
381
+ articles.append({
382
+ 'title': result.get('title', 'Untitled'),
383
+ 'url': result.get('url', '#'),
384
+ 'snippet': result.get('content', '')[:300] + "..."
385
+ })
386
+
387
+ return articles
388
+
389
+ except Exception as e:
390
+ print(f"❌ Article fetch failed: {e}")
391
+ return []
392
+
393
+ def _mock_search(self, topic: str) -> dict:
394
+ """Fallback mock implementation"""
395
+ print(f"πŸ” MOCK SEARCH: Deep dive for '{topic}' (using hardcoded data)")
396
+
397
+ # Simulate different responses for different topics
398
+ if "dream" in topic.lower():
399
+ return {
400
+ "summary": (
401
+ "Dreams are a complex neurological phenomenon, primarily occurring during "
402
+ "REM sleep. Research suggests they are crucial for memory consolidation, "
403
+ "emotional regulation, and problem-solving. The 'fading' "
404
+ "is attributed to the brain's different neurochemical state during sleep, "
405
+ "which is not optimized for encoding new memories."
406
+ ),
407
+ "sources": [
408
+ {"title": "The Science of Dreaming - Scientific American", "url": "https://www.scientificamerican.com/article/the-science-of-dreaming/"},
409
+ {"title": "Why We Dream - Psychology Today", "url": "https://www.psychologytoday.com/us/basics/dreaming"}
410
+ ]
411
+ }
412
+ elif "llm" in topic.lower():
413
+ return {
414
+ "summary": (
415
+ "The debate on LLM 'understanding' is central to AI research. "
416
+ "One view holds they are 'stochastic parrots,' brilliantly matching "
417
+ "statistical patterns without true comprehension. "
418
+ "The opposing view suggests that at their scale, these models "
419
+ "develop emergent, internal world models, representing a new "
420
+ "form of understanding."
421
+ ),
422
+ "sources": [
423
+ {"title": "On the Dangers of Stochastic Parrots - FAccT '21", "url": "https://dl.acm.org/doi/10.1145/3442188.3445922"},
424
+ {"title": "Sparks of AGI: Early experiments with GPT-4", "url": "https://arxiv.org/abs/2303.12712"}
425
+ ]
426
+ }
427
+ else:
428
+ return {
429
+ "summary": (
430
+ f"This is a mock summary about '{topic}'. This agent successfully simulated a "
431
+ "web search. In a real application, this text would be "
432
+ "dynamically generated by an LLM based on live search results "
433
+ "from a tool like Tavily or SerpAPI."
434
+ ),
435
+ "sources": [
436
+ {"title": "Mock Source 1 - Wikipedia", "url": "https://en.wikipedia.org/wiki/Main_Page"},
437
+ {"title": "Mock Source 2 - Example.com", "url": "https://example.com"}
438
+ ]
439
+ }
440
+
441
+ # --- How to refactor to LangGraph (Future Work) ---
442
+ """
443
+ To implement your full "LangGraph" vision, you would:
444
+
445
+ 1. Define a State:
446
+ class BrainDumpState(TypedDict):
447
+ topic: str
448
+ socratic_questions: list[str]
449
+ perspectives: dict
450
+ deep_dive: dict
451
+
452
+ 2. Create Nodes:
453
+ - Each agent's method (e.g., `generate_questions`) becomes a node.
454
+ - def question_node(state: BrainDumpState):
455
+ - questions = QuestionAgent().generate_questions(state['topic'])
456
+ - return {"socratic_questions": questions}
457
+ - ... (similar nodes for perspective_node and search_node)
458
+
459
+ 3. Build the Graph:
460
+ - workflow = StateGraph(BrainDumpState)
461
+ - workflow.add_node("socratic", question_node)
462
+ - workflow.add_node("perspectives", perspective_node)
463
+ - workflow.add_node("search", search_node)
464
+ - workflow.set_entry_point("socratic") # or a router
465
+ - workflow.add_edge("socratic", "perspectives")
466
+ - workflow.add_edge("perspectives", "search")
467
+ - workflow.add_edge("search", END)
468
+
469
+ 4. Compile & Run:
470
+ - app = workflow.compile()
471
+ - # In app.py, you'd call:
472
+ - # results = app.invoke({"topic": "Why do we dream?"})
473
+ - # And then display results['socratic_questions'], etc.
474
+
475
+ This simple class-based approach is used for the Day 2 demo
476
+ as it directly matches your existing app.py implementation.
477
+ """
478
+
479
+
480
+ # ============== 4. Feed Agent (using Perplexity Sonar) ==============
481
+
482
+ class FeedAgent:
483
+ """
484
+ Generates AI-powered summaries for feed cards using Perplexity Sonar.
485
+ If Perplexity is not available, falls back to SearchAgent.
486
+ Also fetches relevant images using Tavily image search.
487
+ """
488
+ def __init__(self, search_agent=None):
489
+ self.search_agent = search_agent
490
+ self.use_perplexity = bool(PERPLEXITY_API_KEY)
491
+ self.perplexity_api_key = PERPLEXITY_API_KEY
492
+ self.tavily_client = TavilyClient(api_key=TAVILY_API_KEY) if TAVILY_API_KEY else None
493
+
494
+ if self.use_perplexity:
495
+ print("βœ… Initialized FeedAgent with Perplexity Sonar")
496
+ else:
497
+ print("ℹ️ FeedAgent will use SearchAgent for summaries")
498
+
499
+ if self.tavily_client:
500
+ print("πŸ“Έ Initialized FeedAgent with Tavily Image Search")
501
+ else:
502
+ print("⚠️ Tavily API key not configured. Image search disabled.")
503
+
504
+ def generate_summary(self, topic: str) -> dict:
505
+ """
506
+ Generate a comprehensive summary using Perplexity Sonar.
507
+ Returns: {"summary": str, "sources": list}
508
+ """
509
+ if self.use_perplexity:
510
+ return self._perplexity_summary(topic)
511
+ elif self.search_agent:
512
+ return self.search_agent.deep_dive(topic)
513
+ else:
514
+ return {
515
+ "summary": "Unable to generate summary. Please configure Perplexity or Tavily API.",
516
+ "sources": []
517
+ }
518
+
519
+ def _perplexity_summary(self, topic: str) -> dict:
520
+ """Generate summary using Perplexity Sonar API"""
521
+ try:
522
+ print(f"🧠 Generating Sonar summary for: {topic}")
523
+
524
+ url = "https://api.perplexity.ai/chat/completions"
525
+ headers = {
526
+ "Authorization": f"Bearer {self.perplexity_api_key}",
527
+ "Content-Type": "application/json"
528
+ }
529
+
530
+ payload = {
531
+ "model": "sonar",
532
+ "messages": [
533
+ {
534
+ "role": "system",
535
+ "content": """You are a brilliant synthesizer of knowledge. Given a brain dump topic,
536
+ provide a comprehensive yet concise summary that:
537
+ 1. Explains the core concept clearly
538
+ 2. Provides practical insights
539
+ 3. Connects to broader contexts
540
+ 4. Sparks further curiosity
541
+
542
+ Keep the tone engaging and thought-provoking."""
543
+ },
544
+ {
545
+ "role": "user",
546
+ "content": f"Provide a comprehensive summary about this brain dump topic: {topic}"
547
+ }
548
+ ],
549
+ "max_tokens": 1500,
550
+ "temperature": 0.7,
551
+ "top_p": 0.9,
552
+ "search_domain_filter": ["perplexity.com"]
553
+ }
554
+
555
+ response = requests.post(url, headers=headers, json=payload)
556
+ response.raise_for_status()
557
+
558
+ data = response.json()
559
+ summary = data.get('choices', [{}])[0].get('message', {}).get('content', 'No summary generated')
560
+
561
+ # Extract citations if available
562
+ sources = []
563
+ if 'citations' in data:
564
+ sources = [{"title": f"Source {i+1}", "url": "#", "snippet": cite}
565
+ for i, cite in enumerate(data['citations'][:3])]
566
+
567
+ return {
568
+ "summary": summary,
569
+ "sources": sources
570
+ }
571
+
572
+ except Exception as e:
573
+ print(f"❌ Perplexity summary generation failed: {e}")
574
+ # Fallback to SearchAgent if available
575
+ if self.search_agent:
576
+ print("Falling back to SearchAgent...")
577
+ return self.search_agent.deep_dive(topic)
578
+
579
+ return {
580
+ "summary": f"Error generating summary: {str(e)}",
581
+ "sources": []
582
+ }
583
+
584
+ def search_images(self, topic: str, max_results: int = 3) -> list[str]:
585
+ """
586
+ Search for relevant images using Tavily image search API.
587
+
588
+ Args:
589
+ topic: The topic to search for images
590
+ max_results: Maximum number of images to return (default: 3)
591
+
592
+ Returns:
593
+ List of image URLs (up to max_results)
594
+ """
595
+ if not self.tavily_client:
596
+ print("⚠️ Tavily API key not configured. Cannot search for images.")
597
+ return []
598
+
599
+ try:
600
+ print(f"πŸ“Έ Searching for images related to: {topic}")
601
+
602
+ # Use Tavily to search for images
603
+ response = self.tavily_client.search(
604
+ query=topic,
605
+ max_results=max_results,
606
+ include_images=True
607
+ )
608
+
609
+ # Extract image URLs from results
610
+ image_urls = []
611
+
612
+ # Try to extract images from the response
613
+ if 'images' in response:
614
+ for img in response['images'][:max_results]:
615
+ if isinstance(img, dict) and 'url' in img:
616
+ image_urls.append(img['url'])
617
+ elif isinstance(img, str):
618
+ image_urls.append(img)
619
+
620
+ # If no images found in dedicated images field, try results
621
+ if not image_urls and 'results' in response:
622
+ for result in response['results'][:max_results]:
623
+ if isinstance(result, dict) and 'image' in result:
624
+ image_urls.append(result['image'])
625
+
626
+ print(f"βœ“ Found {len(image_urls)} relevant images")
627
+ return image_urls[:max_results]
628
+
629
+ except Exception as e:
630
+ print(f"❌ Image search failed: {e}")
631
+ return []
632
+
633
+
634
+ # ============== 5. Brain Dump Generation Agent ==============
635
+
636
+ class GenerationAgent:
637
+ """
638
+ Generates creative braindumps based on a cluster's theme and entries.
639
+ Takes a cluster name and list of entries, uses Gemini to synthesize
640
+ a new braindump that fits the cluster and would be interesting to read.
641
+ """
642
+ def __init__(self, model="gemini-2.5-flash"):
643
+ self.model = model
644
+ self.system_prompt = """
645
+ You are a creative brainstorming agent. You've been given a cluster of related thoughts/brain dumps,
646
+ along with the cluster's theme.
647
+
648
+ Your task is to generate ONE new, engaging brain dump entry that:
649
+ 1. Fits naturally with the theme and existing entries
650
+ 2. Is inspired by the existing entries but presents a NEW angle or question
651
+ 3. Is concise (1-2 sentences), matching the style of the existing entries
652
+ 4. Introduces something the user might find interesting to explore
653
+ 5. Does NOT simply repeat or combine existing entries
654
+
655
+ Generate ONLY the new brain dump text itself - no preamble, no explanation.
656
+ Just the thoughtful question or observation that belongs in this cluster.
657
+ """
658
+
659
+ def generate_braindump(self, cluster_name: str, entries: list[str]) -> str:
660
+ """
661
+ Generates a new braindump for a cluster.
662
+
663
+ Args:
664
+ cluster_name: The name/label of the cluster (e.g., "Dreams and Consciousness")
665
+ entries: List of existing brain dump texts in this cluster
666
+
667
+ Returns:
668
+ Generated brain dump text as a string
669
+ """
670
+ if API_KEY == "YOUR_API_KEY_HERE":
671
+ return "Error: GOOGLE_API_KEY is not set. Please add it to your environment."
672
+
673
+ try:
674
+ # Create Gemini model
675
+ model = genai.GenerativeModel(
676
+ model_name=self.model,
677
+ generation_config={
678
+ "temperature": 0.8, # Higher temperature for more creativity
679
+ }
680
+ )
681
+
682
+ # Format entries for the prompt
683
+ entries_str = "\n".join([f"- {entry}" for entry in entries])
684
+
685
+ # Build the prompt
686
+ prompt = f"""{self.system_prompt}
687
+
688
+ Cluster Theme: "{cluster_name}"
689
+
690
+ Existing entries in this cluster:
691
+ {entries_str}
692
+
693
+ Generate a new, creative brain dump entry that fits this cluster:"""
694
+
695
+ # Generate response
696
+ response = model.generate_content(prompt)
697
+ generated_text = response.text.strip()
698
+
699
+ # Clean up any extra quotes or markers
700
+ if generated_text.startswith('"') and generated_text.endswith('"'):
701
+ generated_text = generated_text[1:-1]
702
+
703
+ return generated_text
704
+
705
+ except Exception as e:
706
+ print(f"Error in GenerationAgent: {e}")
707
+ return f"Error generating braindump: {str(e)}"
app.py ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Brain Dump Sanctuary - Day 2 Complete System
3
+ Includes: Streamlit UI + LangGraph Agent + Multi-Perspective Analysis
4
+
5
+ File structure:
6
+ braindump_sanctuary/
7
+ β”œβ”€β”€ app.py (THIS FILE - run with: streamlit run app.py)
8
+ β”œβ”€β”€ agents.py (LangGraph workflows)
9
+ β”œβ”€β”€ braindump_core.py (from Day 1)
10
+ └── requirements.txt
11
+ """
12
+
13
+ # ============== app.py - MAIN STREAMLIT APP ==============
14
+ import streamlit as st
15
+ import sys
16
+ from datetime import datetime
17
+ import pytz
18
+ import plotly.graph_objects as go
19
+ import numpy as np
20
+ import pandas as pd
21
+ from fuzzywuzzy import fuzz
22
+
23
+ # Import Day 1 components
24
+ from braindump_core import BrainDumpDB, EmbeddingEngine, ClusterEngine, create_knowledge_graph
25
+
26
+ # Import Day 2 components
27
+ from agents import QuestionAgent, SearchAgent, GenerationAgent, FeedAgent
28
+
29
+ # Page config
30
+ st.set_page_config(
31
+ page_title="Brain Dump Sanctuary",
32
+ page_icon="🧠",
33
+ layout="wide"
34
+ )
35
+
36
+ # Initialize session state
37
+ if 'db' not in st.session_state:
38
+ st.session_state.db = BrainDumpDB()
39
+ if 'embedder' not in st.session_state:
40
+ st.session_state.embedder = EmbeddingEngine()
41
+ if 'clusterer' not in st.session_state:
42
+ st.session_state.clusterer = ClusterEngine(min_cluster_size=2)
43
+ if 'search_agent' not in st.session_state:
44
+ st.session_state.search_agent = SearchAgent()
45
+ if 'question_agent' not in st.session_state:
46
+ st.session_state.question_agent = QuestionAgent()
47
+ if 'generation_agent' not in st.session_state:
48
+ st.session_state.generation_agent = GenerationAgent()
49
+ if 'feed_agent' not in st.session_state:
50
+ st.session_state.feed_agent = FeedAgent(search_agent=st.session_state.search_agent)
51
+
52
+ # Sidebar
53
+ with st.sidebar:
54
+ st.title("🧠 Brain Dump Sanctuary")
55
+ st.markdown("Transform stale lists into actionable curiosity")
56
+
57
+ st.divider()
58
+
59
+ # Tab Switcher
60
+ tab_selection = st.radio(
61
+ "Navigate",
62
+ ["Home", "Feed"],
63
+ label_visibility="collapsed"
64
+ )
65
+
66
+ st.divider()
67
+
68
+ # Add new brain dump
69
+ st.subheader("πŸ’­ New Brain Dump")
70
+
71
+ # Use a unique key that changes when we clear
72
+ if 'input_key' not in st.session_state:
73
+ st.session_state.input_key = 0
74
+
75
+ new_dump = st.text_area(
76
+ "What's on your mind?",
77
+ placeholder="Why do dreams feel so real?",
78
+ height=100,
79
+ label_visibility="collapsed",
80
+ key=f"dump_input_{st.session_state.input_key}"
81
+ )
82
+
83
+ col1, col2 = st.columns([3, 1])
84
+ with col1:
85
+ add_clicked = st.button("Add to Sanctuary", type="primary", use_container_width=True)
86
+ with col2:
87
+ clear_clicked = st.button("Clear", use_container_width=True)
88
+
89
+ if add_clicked:
90
+ if new_dump.strip():
91
+ if not st.session_state.get('adding', False):
92
+ st.session_state.adding = True
93
+ dump_id, is_duplicate = st.session_state.db.add_dump(new_dump.strip())
94
+ st.session_state.input_key += 1
95
+ if is_duplicate:
96
+ st.warning("This thought already exists in your sanctuary! πŸ”„")
97
+ else:
98
+ st.success("Added! ✨")
99
+ st.rerun()
100
+ else:
101
+ st.session_state.adding = False
102
+ else:
103
+ st.error("Can't add empty thought!")
104
+
105
+ if clear_clicked:
106
+ if not st.session_state.get('clearing_input', False):
107
+ st.session_state.clearing_input = True
108
+ st.session_state.input_key += 1
109
+ st.rerun()
110
+ else:
111
+ st.session_state.clearing_input = False
112
+
113
+ st.divider()
114
+
115
+ # Actions
116
+ st.subheader("βš™οΈ Actions")
117
+
118
+ if st.button("πŸ”„ Refresh Clusters", use_container_width=True):
119
+ st.info("Clusters will refresh on the Home tab")
120
+
121
+ if st.button("πŸ—‘οΈ Clear All Dumps", use_container_width=True):
122
+ if not st.session_state.get('clearing', False):
123
+ st.session_state.clearing = True
124
+ # Clear all dumps and clusters from Neo4j
125
+ with st.session_state.db.driver.session() as session:
126
+ session.run("MATCH (d:Dump) DETACH DELETE d")
127
+ session.run("MATCH (c:Cluster) DETACH DELETE c")
128
+ st.session_state.input_key = 0
129
+ st.success("All dumps cleared!")
130
+ st.rerun()
131
+ else:
132
+ st.session_state.clearing = False
133
+
134
+ # Stats
135
+ st.divider()
136
+ dumps = st.session_state.db.get_all_dumps()
137
+ st.metric("Total Brain Dumps", len(dumps))
138
+
139
+ # ============== HELPER FUNCTIONS ==============
140
+
141
+ def render_brain_dump_table(dumps, cluster_labels_map):
142
+ """Render brain dumps as a table with cluster labels and timestamps"""
143
+ if not dumps:
144
+ st.info("No brain dumps yet.")
145
+ return
146
+
147
+ # Prepare data for table
148
+ table_data = []
149
+ for dump_id, text, cluster_id, created_at in dumps: # Already sorted by DESC in get_all_dumps()
150
+ cluster_label = "Unclustered"
151
+ if cluster_id is not None and cluster_id != -1 and cluster_id in cluster_labels_map:
152
+ cluster_label = cluster_labels_map[cluster_id]['label']
153
+
154
+ # Format timestamp - handle Neo4j datetime objects and convert to IST
155
+ if created_at:
156
+ try:
157
+ # Neo4j returns a neo4j.time.DateTime object
158
+ # Convert to IST (Indian Standard Time: UTC+5:30)
159
+ ist = pytz.timezone('Asia/Kolkata')
160
+
161
+ # Parse the datetime string if needed
162
+ if isinstance(created_at, str):
163
+ # Try to parse ISO format datetime
164
+ dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
165
+ else:
166
+ # Assume it's a datetime object
167
+ dt = created_at
168
+
169
+ # Make it timezone-aware if it isn't already
170
+ if dt.tzinfo is None:
171
+ # Assume UTC if no timezone
172
+ dt = pytz.UTC.localize(dt)
173
+
174
+ # Convert to IST
175
+ dt_ist = dt.astimezone(ist)
176
+
177
+ # Format as readable string
178
+ created_at_str = dt_ist.strftime("%d %b %Y, %I:%M %p IST")
179
+ except Exception as e:
180
+ print(f"Timestamp conversion error: {e}")
181
+ created_at_str = str(created_at) if created_at else "Unknown"
182
+ else:
183
+ created_at_str = "Unknown"
184
+
185
+ table_data.append({
186
+ "Brain Dump": text,
187
+ "Cluster Label": cluster_label,
188
+ "Created At": created_at_str
189
+ })
190
+
191
+ df = pd.DataFrame(table_data)
192
+ st.dataframe(df, use_container_width=True, hide_index=True)
193
+
194
+
195
+ def render_feed_card(dump_id, text, cluster_id, cluster_labels_map):
196
+ """Render a single blog-style card for a brain dump with agent outputs"""
197
+ with st.container(border=True):
198
+ # Title
199
+ st.markdown(f"### πŸ’­ {text}")
200
+
201
+ # Cluster label badge
202
+ if cluster_id is not None and cluster_id != -1 and cluster_id in cluster_labels_map:
203
+ cluster_label = cluster_labels_map[cluster_id]['label']
204
+ st.markdown(f"🏷️ **{cluster_label}**")
205
+
206
+ st.divider()
207
+
208
+ # Check if we have cached feed data
209
+ cached_data = st.session_state.db.get_feed_cache(dump_id)
210
+
211
+ if cached_data:
212
+ # Use cached summary and questions
213
+ st.markdown("**Summary:**")
214
+ st.write(cached_data['summary'])
215
+ questions = cached_data['questions']
216
+ image_urls = cached_data.get('image_urls', [])
217
+
218
+ # If cached data exists but images are missing, generate them
219
+ if not image_urls:
220
+ with st.spinner("πŸ“Έ Fetching related images..."):
221
+ try:
222
+ image_urls = st.session_state.feed_agent.search_images(text, max_results=3)
223
+ # Update cache with new image URLs
224
+ st.session_state.db.save_feed_cache(dump_id, cached_data['summary'], questions, image_urls)
225
+ except Exception as e:
226
+ print(f"Error searching for images: {str(e)}")
227
+ image_urls = []
228
+ else:
229
+ # Generate summary using Feed Agent
230
+ with st.spinner("🧠 Generating Sonar summary and images..."):
231
+ try:
232
+ result = st.session_state.feed_agent.generate_summary(text)
233
+ summary = result['summary']
234
+ st.markdown("**Summary:**")
235
+ st.write(summary)
236
+
237
+ # Generate questions
238
+ questions = st.session_state.question_agent.generate_questions(text)
239
+
240
+ # Search for related images
241
+ image_urls = st.session_state.feed_agent.search_images(text, max_results=3)
242
+
243
+ # Cache summary, questions, and images
244
+ st.session_state.db.save_feed_cache(dump_id, summary, questions, image_urls)
245
+ except Exception as e:
246
+ st.error(f"Error generating summary: {str(e)}")
247
+ summary = "Unable to generate summary. Please try again."
248
+ questions = []
249
+ image_urls = []
250
+
251
+ # Display images if available
252
+ if image_urls:
253
+ st.divider()
254
+ st.markdown("**Related Images:**")
255
+ cols = st.columns(min(3, len(image_urls))) # Create up to 3 columns
256
+ for idx, image_url in enumerate(image_urls[:3]):
257
+ with cols[idx]:
258
+ try:
259
+ st.image(image_url, use_container_width=True)
260
+ except Exception as e:
261
+ st.caption(f"Could not load image: {image_url}")
262
+
263
+ st.divider()
264
+
265
+ # Display questions (either cached or just generated)
266
+ st.markdown("**Questions for Reflection:**")
267
+ if questions:
268
+ for i, q in enumerate(questions, 1):
269
+ st.markdown(f"{i}. {q}")
270
+ else:
271
+ st.info("No questions available for this brain dump.")
272
+
273
+
274
+ def render_home():
275
+ """Render the Home tab with cluster map, text input, and brain dump table"""
276
+ st.title("🧠 Brain Dump Sanctuary")
277
+ st.markdown("*Where racing thoughts become structured curiosity*")
278
+
279
+ dumps = st.session_state.db.get_all_dumps()
280
+
281
+ if len(dumps) == 0:
282
+ st.info("πŸ‘‹ Welcome! Add your first brain dump using the sidebar.")
283
+
284
+ with st.expander("🎯 Try these example dumps"):
285
+ examples = [
286
+ "Why do dreams feel so real but fade so quickly?",
287
+ "How does quantum entanglement actually work?",
288
+ "Are LLMs actually understanding or just pattern matching?",
289
+ "What causes the smell of rain on dry ground?",
290
+ "Why does time feel faster as we age?",
291
+ ]
292
+ for ex in examples:
293
+ if st.button(f"Add: {ex}", key=ex):
294
+ dump_id, is_duplicate = st.session_state.db.add_dump(ex)
295
+ st.rerun()
296
+ else:
297
+ # Cluster Map Section
298
+ st.subheader("πŸ—ΊοΈ Semantic Cluster Map")
299
+
300
+ if len(dumps) < 3:
301
+ st.warning("⚠️ Add at least 3 brain dumps to see meaningful clusters")
302
+ else:
303
+ try:
304
+ col1, col2 = st.columns([4, 1])
305
+ with col2:
306
+ force_refresh = st.button("πŸ”„ Refresh", help="Recalculate embeddings and clusters")
307
+
308
+ # Check if embeddings already exist for all dumps
309
+ existing_embeddings = st.session_state.db.get_embeddings()
310
+ existing_ids = {emb[0] for emb in existing_embeddings}
311
+ all_dump_ids = {d[0] for d in dumps}
312
+
313
+ # Check if cluster labels already exist
314
+ existing_labels = st.session_state.db.get_all_cluster_labels()
315
+
316
+ # Check if reducer exists (used for cached path)
317
+ has_reducer = st.session_state.clusterer.reducer is not None
318
+
319
+ # Only recalculation if we have new dumps without embeddings OR force refresh OR no reducer yet
320
+ need_recalculation = force_refresh or not (existing_ids >= all_dump_ids) or not has_reducer
321
+
322
+ if need_recalculation:
323
+ with st.spinner("Generating embeddings and clustering..."):
324
+ # Generate embeddings
325
+ texts = [d[1] for d in dumps]
326
+ embeddings = st.session_state.embedder.embed(texts)
327
+
328
+ # Update embeddings in DB
329
+ for i, (dump_id, _, _, _) in enumerate(dumps):
330
+ st.session_state.db.update_embedding(dump_id, embeddings[i])
331
+
332
+ # Cluster
333
+ clusters, coords_2d = st.session_state.clusterer.fit_predict(embeddings)
334
+
335
+ # Track which clusters have changed
336
+ clusters_with_changes = set()
337
+
338
+ # Check for cluster ID changes for each dump
339
+ for i, (dump_id, _, old_cluster_id, _) in enumerate(dumps):
340
+ new_cluster_id = clusters[i]
341
+ if old_cluster_id != new_cluster_id:
342
+ clusters_with_changes.add(new_cluster_id)
343
+ if old_cluster_id is not None and old_cluster_id != -1:
344
+ clusters_with_changes.add(old_cluster_id)
345
+
346
+ # Update clusters in DB
347
+ for i, (dump_id, _, _, _) in enumerate(dumps):
348
+ st.session_state.db.update_cluster(dump_id, clusters[i])
349
+
350
+ # Auto-generate cluster labels for new clusters or changed clusters
351
+ cluster_labels_dict = {}
352
+ unique_clusters = set(clusters) - {-1}
353
+
354
+ if unique_clusters:
355
+ with st.spinner("Generating cluster labels with Gemini..."):
356
+ for cluster_id in unique_clusters:
357
+ # Re-label if cluster is new OR if it had changes
358
+ if cluster_id not in existing_labels or cluster_id in clusters_with_changes:
359
+ cluster_dumps = [dumps[i][1] for i in range(len(dumps)) if clusters[i] == cluster_id]
360
+ label = st.session_state.clusterer.generate_cluster_label(cluster_dumps)
361
+ cluster_labels_dict[cluster_id] = label
362
+ st.session_state.db.save_cluster_label(cluster_id, label)
363
+ else:
364
+ cluster_labels_dict[cluster_id] = existing_labels[cluster_id]['label']
365
+
366
+ # Generate and cache feed data for new/uncached dumps
367
+ with st.spinner("Generating feed summaries and questions..."):
368
+ for i, (dump_id, text, _, _) in enumerate(dumps):
369
+ # Check if feed cache already exists
370
+ if not st.session_state.db.get_feed_cache(dump_id):
371
+ try:
372
+ # Generate summary
373
+ summary_result = st.session_state.feed_agent.generate_summary(text)
374
+ summary = summary_result['summary']
375
+
376
+ # Generate questions
377
+ questions = st.session_state.question_agent.generate_questions(text)
378
+
379
+ # Cache both
380
+ st.session_state.db.save_feed_cache(dump_id, summary, questions)
381
+ except Exception as e:
382
+ print(f"Warning: Could not generate feed cache for {dump_id}: {e}")
383
+ # Continue without caching for this dump
384
+ else:
385
+ # Use existing embeddings and clusters
386
+ st.info("πŸ“¦ Using cached embeddings and clusters")
387
+
388
+ # Load existing embeddings
389
+ embeddings_list = []
390
+ for dump_id, _, _, _ in dumps:
391
+ matching_emb = next((emb[1] for emb in existing_embeddings if emb[0] == dump_id), None)
392
+ if matching_emb is not None:
393
+ embeddings_list.append(matching_emb)
394
+
395
+ embeddings = np.array(embeddings_list)
396
+
397
+ # Get clusters from database
398
+ clusters = np.array([d[2] if d[2] is not None else -1 for d in dumps])
399
+
400
+ # Get 2D coordinates for visualization
401
+ coords_2d = st.session_state.clusterer.reducer.fit_transform(embeddings)
402
+
403
+ # Load existing cluster labels
404
+ cluster_labels_dict = {k: v['label'] for k, v in existing_labels.items()}
405
+ unique_clusters = set(clusters) - {-1}
406
+
407
+ # Visualize with labels
408
+ fig = create_knowledge_graph(dumps, coords_2d, clusters, cluster_labels_dict, embeddings)
409
+ st.plotly_chart(fig, use_container_width=True)
410
+
411
+ except Exception as e:
412
+ st.error(f"❌ Clustering error: {str(e)}")
413
+ if st.checkbox("Show technical details"):
414
+ st.exception(e)
415
+
416
+ st.divider()
417
+
418
+ # Consolidated Cluster Generation Section
419
+ st.subheader("🧬 Generate New Brain Dumps for All Clusters")
420
+
421
+ col1, col2 = st.columns([4, 1])
422
+ with col1:
423
+ st.markdown("*Generate one new braindump for each cluster in a single click*")
424
+
425
+ with col2:
426
+ if st.button("✨ Generate All", key="gen_all_clusters", help="Generate a new braindump for each cluster", type="primary"):
427
+ try:
428
+ # Get all clusters with their dumps
429
+ all_cluster_labels = st.session_state.db.get_all_cluster_labels()
430
+
431
+ if all_cluster_labels:
432
+ generated_count = 0
433
+ failed_clusters = []
434
+
435
+ with st.spinner("πŸ€– Generating braindumps for all clusters..."):
436
+ generated_dumps = [] # Track generated dumps and their cluster IDs
437
+
438
+ for cluster_id, cluster_info in all_cluster_labels.items():
439
+ cluster_label = cluster_info['label']
440
+
441
+ # Skip clusters with None label
442
+ if cluster_label is None:
443
+ failed_clusters.append(f"Cluster {cluster_id} (no label)")
444
+ continue
445
+
446
+ # Get dumps in this cluster
447
+ cluster_dumps = st.session_state.db.get_cluster_dumps(cluster_id)
448
+ if cluster_dumps:
449
+ cluster_dump_texts = [d[1] for d in cluster_dumps]
450
+
451
+ try:
452
+ # Generate the braindump
453
+ generated_text = st.session_state.generation_agent.generate_braindump(
454
+ cluster_name=cluster_label,
455
+ entries=cluster_dump_texts
456
+ )
457
+
458
+ # Check if there was an error
459
+ if not generated_text.startswith("Error"):
460
+ # Add to database with cluster assignment
461
+ new_dump_id, is_duplicate = st.session_state.db.add_generated_dump(generated_text, cluster_id)
462
+
463
+ if not is_duplicate:
464
+ # Compute embedding for the generated dump immediately
465
+ # so it doesn't trigger a full recalculation on rerun
466
+ generated_embedding = st.session_state.embedder.embed([generated_text])[0]
467
+ st.session_state.db.update_embedding(new_dump_id, generated_embedding)
468
+
469
+ generated_dumps.append((new_dump_id, generated_text, cluster_id))
470
+ generated_count += 1
471
+ else:
472
+ # Duplicate found, skip this generated dump
473
+ pass
474
+ else:
475
+ failed_clusters.append(cluster_label)
476
+ except Exception as e:
477
+ print(f"Error generating for {cluster_label}: {e}")
478
+ failed_clusters.append(cluster_label)
479
+
480
+ # Show results
481
+ if generated_count > 0:
482
+ st.success(f"✨ Generated {generated_count} new brain dump{'s' if generated_count != 1 else ''}!")
483
+
484
+ if failed_clusters:
485
+ st.warning(f"⚠️ Could not generate for: {', '.join(failed_clusters)}")
486
+
487
+ if generated_count > 0:
488
+ st.rerun()
489
+ else:
490
+ st.info("πŸ’‘ No clusters available yet.")
491
+ except Exception as e:
492
+ st.error(f"Error generating braindumps: {str(e)}")
493
+
494
+ st.divider()
495
+
496
+ # Brain Dump Table Section
497
+ st.subheader("πŸ“‹ All Brain Dumps")
498
+ cluster_labels_map = st.session_state.db.get_all_cluster_labels()
499
+ render_brain_dump_table(dumps, cluster_labels_map)
500
+
501
+
502
+ def render_feed():
503
+ """Render the Feed tab with blog-style cards for 5 most recent brain dumps"""
504
+ st.title("πŸ“° Feed")
505
+ st.markdown("*Agent-generated insights for your most recent thoughts*")
506
+
507
+ dumps = st.session_state.db.get_all_dumps()
508
+
509
+ if len(dumps) == 0:
510
+ st.info("πŸ‘‹ No brain dumps yet. Add one using the sidebar to get started!")
511
+ else:
512
+ cluster_labels_map = st.session_state.db.get_all_cluster_labels()
513
+
514
+ # Initialize search mode state if not exists
515
+ if 'search_mode' not in st.session_state:
516
+ st.session_state.search_mode = False
517
+ if 'selected_dump_id' not in st.session_state:
518
+ st.session_state.selected_dump_id = None
519
+
520
+ # Search and filter section
521
+ col1, col2 = st.columns([4, 1])
522
+
523
+ with col1:
524
+ search_query = st.text_input(
525
+ "πŸ” Search brain dumps",
526
+ placeholder="Type to search...",
527
+ key="feed_search",
528
+ help="Search using fuzzy matching (handles typos)"
529
+ )
530
+
531
+ # Perform fuzzy matching using fuzzywuzzy
532
+ suggestions = []
533
+ if search_query.strip():
534
+ # Calculate fuzzy match score for each dump
535
+ for dump_id, text, cluster_id, created_at in dumps:
536
+ # Use first 100 chars of text for matching
537
+ match_text = text[:100]
538
+ # Use token_sort_ratio for better matching with word order variations
539
+ score = fuzz.token_sort_ratio(search_query.lower(), match_text.lower())
540
+ suggestions.append({
541
+ 'dump_id': dump_id,
542
+ 'text': text,
543
+ 'cluster_id': cluster_id,
544
+ 'score': score,
545
+ 'match_text': match_text
546
+ })
547
+
548
+ # Sort by score (higher = better match) and take top 8
549
+ suggestions = sorted(suggestions, key=lambda x: x['score'], reverse=True)[:8]
550
+
551
+ if suggestions:
552
+ st.write(f"**Found {len(suggestions)} best matches:**")
553
+
554
+ # Create dropdown options (truncated text)
555
+ suggestion_texts = [
556
+ s['text'][:70] + "..." if len(s['text']) > 70 else s['text']
557
+ for s in suggestions
558
+ ]
559
+
560
+ # Show suggestions as a dropdown with match score
561
+ selected_idx = st.selectbox(
562
+ "Select a brain dump",
563
+ range(len(suggestions)),
564
+ format_func=lambda i: f"({suggestions[i]['score']}%) {suggestion_texts[i]}",
565
+ key="feed_suggestions_dropdown",
566
+ help="Suggestions ranked by relevance (higher % = better match)"
567
+ )
568
+
569
+ if selected_idx is not None:
570
+ st.session_state.search_mode = True
571
+ st.session_state.selected_dump_id = suggestions[selected_idx]['dump_id']
572
+ else:
573
+ st.warning(f"No brain dumps found for '{search_query}'")
574
+ else:
575
+ # No search query - clear search mode
576
+ st.session_state.search_mode = False
577
+ st.session_state.selected_dump_id = None
578
+
579
+ st.divider()
580
+
581
+ # Display selected dump or recent dumps
582
+ if st.session_state.search_mode and st.session_state.selected_dump_id:
583
+ # Show single selected dump with back button
584
+ col1, col2 = st.columns([4, 1])
585
+
586
+ with col2:
587
+ if st.button("← Back to Feed", key="back_to_feed"):
588
+ st.session_state.search_mode = False
589
+ st.session_state.selected_dump_id = None
590
+ st.rerun()
591
+
592
+ # Find the selected dump
593
+ selected_dump = next(
594
+ (d for d in dumps if d[0] == st.session_state.selected_dump_id),
595
+ None
596
+ )
597
+
598
+ if selected_dump:
599
+ dump_id, text, cluster_id, created_at = selected_dump
600
+ st.markdown("### Single Brain Dump View")
601
+ render_feed_card(dump_id, text, cluster_id, cluster_labels_map)
602
+ else:
603
+ # Show 5 most recent dumps (default view)
604
+ recent_dumps = dumps[:5]
605
+ st.markdown(f"Showing **{len(recent_dumps)}** most recent brain dumps")
606
+ st.divider()
607
+
608
+ for dump_id, text, cluster_id, created_at in recent_dumps:
609
+ render_feed_card(dump_id, text, cluster_id, cluster_labels_map)
610
+ st.markdown("") # Spacing between cards
611
+
612
+
613
+ # ============== MAIN APP ==============
614
+ # Main content
615
+ st.markdown("") # Spacing
616
+
617
+ # Get all dumps
618
+ dumps = st.session_state.db.get_all_dumps()
619
+
620
+ # Render selected tab
621
+ if tab_selection == "Home":
622
+ render_home()
623
+ else:
624
+ render_feed()
625
+
626
+ # Footer
627
+ st.divider()
628
+ st.markdown("*Built with Google Gemini, Tavily Search, and Streamlit*")
braindump_core.py ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Brain Dump Sanctuary - Core Pipeline (Day 1 MVP + LangChain Integration)
3
+ Now with Neo4j Graph Database Backend
4
+ Runs entirely on CPU - no GPU needed
5
+ """
6
+
7
+ import numpy as np
8
+ from sentence_transformers import SentenceTransformer
9
+ from sklearn.cluster import HDBSCAN
10
+ import umap
11
+ import plotly.graph_objects as go
12
+ from datetime import datetime
13
+ import json
14
+ import os
15
+ from dotenv import load_dotenv
16
+ from neo4j import GraphDatabase
17
+ from neo4j.exceptions import ServiceUnavailable, AuthError
18
+
19
+ # Load environment variables from .env file
20
+ load_dotenv()
21
+
22
+ # LangChain imports for cluster labeling with Gemini
23
+ from langchain_google_genai import ChatGoogleGenerativeAI
24
+ from langchain_core.prompts import ChatPromptTemplate
25
+ from langchain_core.output_parsers import StrOutputParser
26
+ from langchain_core.runnables import RunnableWithFallbacks
27
+
28
+ # ============== 1. DATABASE LAYER - Neo4j ==============
29
+ class BrainDumpDB:
30
+ """
31
+ Neo4j-based graph database for Brain Dump Sanctuary.
32
+
33
+ Graph Schema:
34
+ - Node: Dump {id, text, embedding[], cluster_id, created_at}
35
+ - Node: Cluster {id, label, description, created_at}
36
+ - Relationship: Dump -[:IN_CLUSTER]-> Cluster
37
+ - Relationship: Dump -[:SIMILAR_TO {weight}]-> Dump
38
+ """
39
+
40
+ def __init__(self):
41
+ """Initialize Neo4j connection from environment variables."""
42
+ self.uri = os.getenv("NEO4J_URI", "neo4j://localhost:7687")
43
+ self.user = os.getenv("NEO4J_USER", "neo4j")
44
+ self.password = os.getenv("NEO4J_PASSWORD", "password")
45
+
46
+ try:
47
+ self.driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password))
48
+ self.driver.verify_connectivity()
49
+ print(f"βœ“ Connected to Neo4j at {self.uri}")
50
+ except (ServiceUnavailable, AuthError) as e:
51
+ print(f"βœ— Failed to connect to Neo4j: {e}")
52
+ print(" Make sure NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD are set in .env")
53
+ raise
54
+
55
+ self._init_schema()
56
+
57
+ def _init_schema(self):
58
+ """Initialize graph schema with nodes and indexes."""
59
+ with self.driver.session() as session:
60
+ # Create Dump nodes with indexes
61
+ session.run("""
62
+ CREATE INDEX dump_id_index IF NOT EXISTS
63
+ FOR (d:Dump) ON (d.id)
64
+ """)
65
+ session.run("""
66
+ CREATE INDEX dump_created_index IF NOT EXISTS
67
+ FOR (d:Dump) ON (d.created_at)
68
+ """)
69
+
70
+ # Create Cluster nodes with indexes
71
+ session.run("""
72
+ CREATE INDEX cluster_id_index IF NOT EXISTS
73
+ FOR (c:Cluster) ON (c.id)
74
+ """)
75
+
76
+ print("βœ“ Graph schema initialized")
77
+
78
+ def check_duplicate(self, text):
79
+ """
80
+ Check if a dump with the same text already exists in the database.
81
+ Returns the dump_id if duplicate found, None otherwise.
82
+ """
83
+ with self.driver.session() as session:
84
+ result = session.run("""
85
+ MATCH (d:Dump {text: $text})
86
+ RETURN d.id as dump_id
87
+ LIMIT 1
88
+ """, text=text)
89
+ row = result.single()
90
+ if row:
91
+ return row["dump_id"]
92
+ return None
93
+
94
+ def add_dump(self, text):
95
+ """
96
+ Add a new brain dump to the database.
97
+ Returns (dump_id, is_duplicate) tuple.
98
+ If duplicate exists, returns (existing_dump_id, True).
99
+ If new dump created, returns (new_dump_id, False).
100
+ """
101
+ # Check for duplicate first
102
+ existing_id = self.check_duplicate(text)
103
+ if existing_id:
104
+ return (existing_id, True)
105
+
106
+ # No duplicate, create new dump
107
+ with self.driver.session() as session:
108
+ result = session.run("""
109
+ CREATE (d:Dump {
110
+ id: randomUuid(),
111
+ text: $text,
112
+ created_at: datetime()
113
+ })
114
+ RETURN d.id as dump_id
115
+ """, text=text)
116
+ dump_id = result.single()["dump_id"]
117
+ return (dump_id, False)
118
+
119
+ def get_all_dumps(self):
120
+ """Get all dumps with their cluster assignments and timestamps."""
121
+ with self.driver.session() as session:
122
+ result = session.run("""
123
+ MATCH (d:Dump)
124
+ OPTIONAL MATCH (d)-[:IN_CLUSTER]->(c:Cluster)
125
+ RETURN d.id as id, d.text as text, c.id as cluster_id, d.created_at as created_at
126
+ ORDER BY d.created_at DESC
127
+ """)
128
+ return [(row["id"], row["text"], row["cluster_id"], row["created_at"]) for row in result]
129
+
130
+ def update_embedding(self, dump_id, embedding):
131
+ """Store embedding vector for a dump."""
132
+ # Convert numpy array to list for Neo4j storage
133
+ embedding_list = embedding.astype(np.float32).tolist()
134
+
135
+ with self.driver.session() as session:
136
+ session.run("""
137
+ MATCH (d:Dump {id: $dump_id})
138
+ SET d.embedding = $embedding
139
+ """, dump_id=dump_id, embedding=embedding_list)
140
+
141
+ def update_cluster(self, dump_id, cluster_id):
142
+ """Assign dump to a cluster."""
143
+ with self.driver.session() as session:
144
+ # Remove existing cluster relationship
145
+ session.run("""
146
+ MATCH (d:Dump {id: $dump_id})-[r:IN_CLUSTER]->()
147
+ DELETE r
148
+ """, dump_id=dump_id)
149
+
150
+ # Create or match cluster node and add relationship
151
+ session.run("""
152
+ MATCH (d:Dump {id: $dump_id})
153
+ MERGE (c:Cluster {id: $cluster_id})
154
+ ON CREATE SET c.created_at = datetime()
155
+ CREATE (d)-[:IN_CLUSTER]->(c)
156
+ """, dump_id=dump_id, cluster_id=int(cluster_id))
157
+
158
+ def get_embeddings(self):
159
+ """Retrieve all embeddings from database."""
160
+ with self.driver.session() as session:
161
+ result = session.run("""
162
+ MATCH (d:Dump)
163
+ WHERE d.embedding IS NOT NULL
164
+ RETURN d.id as dump_id, d.embedding as embedding
165
+ """)
166
+ embeddings = []
167
+ for row in result:
168
+ dump_id = row["dump_id"]
169
+ embedding_list = row["embedding"]
170
+ # Convert list back to numpy array
171
+ embedding = np.array(embedding_list, dtype=np.float32)
172
+ embeddings.append((dump_id, embedding))
173
+ return embeddings
174
+
175
+ def save_cluster_label(self, cluster_id, label, description=None):
176
+ """Save or update a cluster label."""
177
+ with self.driver.session() as session:
178
+ session.run("""
179
+ MERGE (c:Cluster {id: $cluster_id})
180
+ ON CREATE SET c.created_at = datetime()
181
+ SET c.label = $label, c.description = $description
182
+ """, cluster_id=int(cluster_id), label=label, description=description)
183
+
184
+ def get_cluster_label(self, cluster_id):
185
+ """Get label for a specific cluster."""
186
+ with self.driver.session() as session:
187
+ result = session.run("""
188
+ MATCH (c:Cluster {id: $cluster_id})
189
+ RETURN c.label as label, c.description as description
190
+ """, cluster_id=int(cluster_id))
191
+ row = result.single()
192
+ if row:
193
+ return (row["label"], row["description"])
194
+ return (None, None)
195
+
196
+ def get_all_cluster_labels(self):
197
+ """Get all cluster labels."""
198
+ with self.driver.session() as session:
199
+ result = session.run("""
200
+ MATCH (c:Cluster)
201
+ RETURN c.id as cluster_id, c.label as label, c.description as description
202
+ """)
203
+ return {
204
+ row["cluster_id"]: {
205
+ "label": row["label"],
206
+ "description": row["description"]
207
+ }
208
+ for row in result
209
+ }
210
+
211
+ def get_cluster_dumps(self, cluster_id):
212
+ """Get all dumps in a specific cluster."""
213
+ with self.driver.session() as session:
214
+ result = session.run("""
215
+ MATCH (d:Dump)-[:IN_CLUSTER]->(c:Cluster {id: $cluster_id})
216
+ RETURN d.id as dump_id, d.text as text
217
+ """, cluster_id=int(cluster_id))
218
+ return [(row["dump_id"], row["text"]) for row in result]
219
+
220
+ def add_generated_dump(self, text, cluster_id):
221
+ """
222
+ Add a generated braindump directly to a specific cluster.
223
+ Returns (dump_id, is_duplicate) tuple.
224
+ If duplicate exists, returns (existing_dump_id, True) without adding to cluster.
225
+ If new dump created, returns (new_dump_id, False).
226
+ """
227
+ # Check for duplicate first
228
+ existing_id = self.check_duplicate(text)
229
+ if existing_id:
230
+ return (existing_id, True)
231
+
232
+ # No duplicate, create new dump and assign to cluster
233
+ with self.driver.session() as session:
234
+ result = session.run("""
235
+ CREATE (d:Dump {
236
+ id: randomUuid(),
237
+ text: $text,
238
+ created_at: datetime()
239
+ })
240
+ WITH d
241
+ MATCH (c:Cluster {id: $cluster_id})
242
+ CREATE (d)-[:IN_CLUSTER]->(c)
243
+ RETURN d.id as dump_id
244
+ """, text=text, cluster_id=int(cluster_id))
245
+ dump_id = result.single()["dump_id"]
246
+ return (dump_id, False)
247
+
248
+ def save_feed_cache(self, dump_id, summary, questions, image_urls=None):
249
+ """
250
+ Store cached feed data (summary, questions, and image URLs) for a dump.
251
+
252
+ Args:
253
+ dump_id: ID of the dump
254
+ summary: Summary text from FeedAgent
255
+ questions: List of reflection questions
256
+ image_urls: List of image URLs related to the brain dump
257
+ """
258
+ with self.driver.session() as session:
259
+ # Store as JSON strings for Neo4j compatibility
260
+ questions_json = json.dumps(questions) if isinstance(questions, list) else questions
261
+ image_urls_json = json.dumps(image_urls) if image_urls else json.dumps([])
262
+
263
+ session.run("""
264
+ MATCH (d:Dump {id: $dump_id})
265
+ SET d.summary = $summary,
266
+ d.questions = $questions,
267
+ d.image_urls = $image_urls,
268
+ d.feed_cache_generated_at = datetime()
269
+ """, dump_id=dump_id, summary=summary, questions=questions_json, image_urls=image_urls_json)
270
+
271
+ def get_feed_cache(self, dump_id):
272
+ """
273
+ Retrieve cached feed data for a dump.
274
+ Returns: {"summary": str, "questions": list, "image_urls": list} or None if not cached
275
+ """
276
+ with self.driver.session() as session:
277
+ result = session.run("""
278
+ MATCH (d:Dump {id: $dump_id})
279
+ RETURN d.summary as summary, d.questions as questions, d.image_urls as image_urls
280
+ """, dump_id=dump_id)
281
+
282
+ row = result.single()
283
+ if row and row["summary"]:
284
+ questions = json.loads(row["questions"]) if row["questions"] else []
285
+ image_urls = json.loads(row["image_urls"]) if row["image_urls"] else []
286
+ return {
287
+ "summary": row["summary"],
288
+ "questions": questions,
289
+ "image_urls": image_urls
290
+ }
291
+ return None
292
+
293
+ def close(self):
294
+ """Close database connection."""
295
+ self.driver.close()
296
+
297
+
298
+ # ============== 2. EMBEDDING ENGINE ==============
299
+ class EmbeddingEngine:
300
+ def __init__(self, model_name='all-MiniLM-L6-v2'):
301
+ print(f"Loading embedding model: {model_name}")
302
+ self.model = SentenceTransformer(model_name)
303
+ print("βœ“ Model loaded (CPU mode)")
304
+
305
+ def embed(self, texts):
306
+ """Fast CPU inference - 5-10ms per text"""
307
+ if isinstance(texts, str):
308
+ texts = [texts]
309
+ embeddings = self.model.encode(texts, show_progress_bar=True)
310
+ return embeddings
311
+
312
+
313
+ # ============== 3. CLUSTERING ENGINE ==============
314
+ class ClusterEngine:
315
+ def __init__(self, min_cluster_size=3):
316
+ self.min_cluster_size = min_cluster_size
317
+ self.clusterer = None
318
+ self.reducer = None
319
+ self.llm = None
320
+ self.labeling_chain = None
321
+
322
+ # Initialize LangChain for cluster labeling
323
+ self._init_labeling_chain()
324
+
325
+ def _init_labeling_chain(self):
326
+ """Initialize LangChain chain for automatic cluster labeling with Google Gemini"""
327
+ try:
328
+ api_key = os.environ.get("GOOGLE_API_KEY")
329
+ if not api_key:
330
+ print("⚠️ GOOGLE_API_KEY not found. Cluster labeling will be disabled.")
331
+ print(" Get your API key from: https://aistudio.google.com/app/apikey")
332
+ return
333
+
334
+ # Create primary Gemini LLM
335
+ primary_llm = ChatGoogleGenerativeAI(
336
+ model="gemini-2.5-flash",
337
+ temperature=0.3,
338
+ google_api_key=api_key
339
+ )
340
+
341
+ # Create fallback Gemini LLM (using same model but could use gemini-pro)
342
+ fallback_llm = ChatGoogleGenerativeAI(
343
+ model="gemini-2.5-flash",
344
+ temperature=0.5,
345
+ google_api_key=api_key
346
+ )
347
+
348
+ # LLM with fallback support
349
+ self.llm = primary_llm.with_fallbacks([fallback_llm])
350
+
351
+ # Create labeling prompt
352
+ prompt = ChatPromptTemplate.from_template(
353
+ """You are an expert at identifying themes in clusters of related thoughts.
354
+
355
+ Given these brain dump entries from a cluster:
356
+ {cluster_texts}
357
+
358
+ Task: Generate a short, descriptive label (2-4 words) that captures the unifying theme.
359
+
360
+ Rules:
361
+ - Be specific and insightful
362
+ - Use natural language, not generic terms
363
+ - Focus on the underlying curiosity or topic
364
+ - Be specific if entries are less.
365
+ - Be broad if entries are more.
366
+
367
+ Label:"""
368
+ )
369
+
370
+ # Create the chain
371
+ self.labeling_chain = (
372
+ prompt
373
+ | self.llm
374
+ | StrOutputParser()
375
+ )
376
+
377
+ print("βœ“ LangChain cluster labeling initialized with Google Gemini")
378
+
379
+ except Exception as e:
380
+ print(f"⚠️ Could not initialize cluster labeling: {e}")
381
+ self.llm = None
382
+ self.labeling_chain = None
383
+
384
+ def generate_cluster_label(self, cluster_texts):
385
+ """Generate a label for a cluster using LangChain"""
386
+ if not self.labeling_chain:
387
+ return "Cluster"
388
+
389
+ try:
390
+ # Take up to 5 representative texts
391
+ sample_texts = cluster_texts[:5]
392
+ texts_str = "\n".join([f"- {text}" for text in sample_texts])
393
+
394
+ label = self.labeling_chain.invoke({"cluster_texts": texts_str})
395
+ return label.strip()
396
+
397
+ except Exception as e:
398
+ print(f"⚠️ Cluster labeling failed: {e}")
399
+ return "Cluster"
400
+
401
+ def fit_predict(self, embeddings):
402
+ """HDBSCAN clustering - works great on CPU"""
403
+ print(f"Clustering {len(embeddings)} brain dumps...")
404
+
405
+ n_samples = len(embeddings)
406
+
407
+ # Adjust min_cluster_size for small datasets
408
+ effective_min_cluster_size = min(self.min_cluster_size, max(2, n_samples // 2))
409
+
410
+ # HDBSCAN for semantic clustering
411
+ self.clusterer = HDBSCAN(
412
+ min_cluster_size=effective_min_cluster_size,
413
+ metric='euclidean',
414
+ cluster_selection_method='eom',
415
+ min_samples=1 # More lenient clustering
416
+ )
417
+ clusters = self.clusterer.fit_predict(embeddings)
418
+
419
+ # UMAP for 2D visualization with better separation parameters
420
+ # n_neighbors controls local vs global structure (lower = tighter clusters)
421
+ n_neighbors = max(2, min(10, n_samples - 1)) # Reduced from 15 for tighter clusters
422
+
423
+ # For very small datasets, use simpler initialization
424
+ init = 'spectral' if n_samples > 10 else 'random'
425
+
426
+ self.reducer = umap.UMAP(
427
+ n_components=2,
428
+ random_state=42,
429
+ n_neighbors=n_neighbors,
430
+ min_dist=0.3, # Increased from 0.1 for better separation between clusters
431
+ spread=1.5, # Controls how clumped embeddings are (higher = more spread)
432
+ metric='euclidean',
433
+ init=init,
434
+ negative_sample_rate=10, # Helps with separation
435
+ repulsion_strength=1.2 # Pushes dissimilar points apart
436
+ )
437
+ coords_2d = self.reducer.fit_transform(embeddings)
438
+
439
+ print(f"βœ“ Found {len(set(clusters)) - (1 if -1 in clusters else 0)} clusters")
440
+ return clusters, coords_2d
441
+
442
+
443
+ # ============== 4. VISUALIZATION ==============
444
+ def build_graph_edges(embeddings, clusters, max_edges_per_node=5, similarity_threshold=0.7):
445
+ """Build edges between semantically similar items (Obsidian knowledge graph style)"""
446
+ from sklearn.metrics.pairwise import cosine_similarity
447
+
448
+ edges = []
449
+ similarities = cosine_similarity(embeddings)
450
+
451
+ # Add edges between items in same cluster
452
+ unique_clusters = set(clusters)
453
+ for cluster_id in unique_clusters:
454
+ if cluster_id == -1: # Skip noise
455
+ continue
456
+
457
+ cluster_indices = np.where(clusters == cluster_id)[0]
458
+
459
+ # Connect items within cluster (up to max_edges_per_node each)
460
+ for i in cluster_indices:
461
+ # Find most similar items in same cluster
462
+ cluster_similarities = [
463
+ (j, similarities[i][j]) for j in cluster_indices if i != j
464
+ ]
465
+ cluster_similarities.sort(key=lambda x: x[1], reverse=True)
466
+
467
+ for j, sim in cluster_similarities[:max_edges_per_node]:
468
+ if i < j: # Avoid duplicates
469
+ edges.append((i, j, sim))
470
+
471
+ return edges
472
+
473
+
474
+ def create_knowledge_graph(dump_data, coords_2d, clusters, cluster_labels=None, embeddings=None):
475
+ """
476
+ Obsidian-style knowledge graph visualization with force-directed layout.
477
+ Shows connections between related brain dumps in a network style.
478
+ """
479
+
480
+ fig = go.Figure()
481
+
482
+ # Color palette for clusters
483
+ cluster_colors = [
484
+ '#FF6B6B', # Red
485
+ '#4ECDC4', # Teal
486
+ '#45B7D1', # Sky Blue
487
+ '#FFA07A', # Light Salmon
488
+ '#98D8C8', # Mint
489
+ '#F7DC6F', # Yellow
490
+ '#BB8FCE', # Purple
491
+ '#85C1E2', # Light Blue
492
+ '#F8B739', # Orange
493
+ '#52BE80', # Green
494
+ '#EC7063', # Coral
495
+ '#AF7AC5', # Lavender
496
+ '#5DADE2', # Ocean Blue
497
+ '#48C9B0', # Turquoise
498
+ '#F1948A', # Pink
499
+ '#85929E', # Gray Blue
500
+ '#F39C12', # Dark Orange
501
+ '#3498DB', # Bright Blue
502
+ '#E74C3C', # Bright Red
503
+ '#9B59B6' # Violet
504
+ ]
505
+
506
+ # Color map for clusters
507
+ unique_clusters = set(clusters)
508
+ colors = {-1: '#95A5A6'} # Gray for noise points
509
+
510
+ for i, c in enumerate([c for c in unique_clusters if c != -1]):
511
+ colors[c] = cluster_colors[i % len(cluster_colors)]
512
+
513
+ # Build edges between related items
514
+ edges = []
515
+ if embeddings is not None:
516
+ edges = build_graph_edges(embeddings, clusters)
517
+
518
+ # Draw edges first (so they appear behind nodes)
519
+ for i, j, similarity in edges:
520
+ x0, y0 = coords_2d[i]
521
+ x1, y1 = coords_2d[j]
522
+
523
+ # Edge opacity based on similarity
524
+ edge_opacity = 0.2 + (similarity - 0.7) * 0.4 # Range: 0.2-0.6
525
+ edge_opacity = max(0.1, min(0.6, edge_opacity))
526
+
527
+ fig.add_trace(go.Scatter(
528
+ x=[x0, x1, None],
529
+ y=[y0, y1, None],
530
+ mode='lines',
531
+ line=dict(
532
+ width=1.5,
533
+ color=f'rgba(200, 200, 200, {edge_opacity})',
534
+ ),
535
+ hoverinfo='none',
536
+ showlegend=False,
537
+ name='',
538
+ ))
539
+
540
+ # Draw nodes (clusters)
541
+ for cluster_id in unique_clusters:
542
+ mask = clusters == cluster_id
543
+ cluster_coords = coords_2d[mask]
544
+ cluster_texts = [dump_data[i][1] for i in range(len(dump_data)) if clusters[i] == cluster_id]
545
+
546
+ # Get cluster label if available
547
+ if cluster_labels and cluster_id in cluster_labels:
548
+ label = cluster_labels[cluster_id]
549
+ else:
550
+ label = f"Cluster {cluster_id}" if cluster_id != -1 else "Unclustered"
551
+
552
+ # Determine node size (larger for bigger clusters)
553
+ node_size = min(24, 14 + len(cluster_texts) // 2)
554
+
555
+ fig.add_trace(go.Scatter(
556
+ x=cluster_coords[:, 0],
557
+ y=cluster_coords[:, 1],
558
+ mode='markers',
559
+ name=label,
560
+ marker=dict(
561
+ size=node_size,
562
+ color=colors[cluster_id],
563
+ line=dict(width=2, color='rgba(255, 255, 255, 0.8)'),
564
+ opacity=0.95,
565
+ symbol='circle',
566
+ ),
567
+ text=cluster_texts,
568
+ hovertext=[f"<b>{text}</b>" for text in cluster_texts],
569
+ hoverinfo='text',
570
+ showlegend=True,
571
+ ))
572
+
573
+ fig.update_layout(
574
+ title={
575
+ 'text': "🧠 Brain Dump Sanctuary - Knowledge Graph",
576
+ 'x': 0.5,
577
+ 'xanchor': 'center',
578
+ 'font': {'size': 20, 'color': 'white'}
579
+ },
580
+ showlegend=True,
581
+ hovermode='closest',
582
+ width=1400,
583
+ height=800,
584
+ plot_bgcolor='#0d1117',
585
+ paper_bgcolor='#0d1117',
586
+ font=dict(color='white', family='monospace'),
587
+ xaxis=dict(
588
+ showgrid=False,
589
+ zeroline=False,
590
+ showticklabels=False,
591
+ showline=False,
592
+ ),
593
+ yaxis=dict(
594
+ showgrid=False,
595
+ zeroline=False,
596
+ showticklabels=False,
597
+ showline=False,
598
+ ),
599
+ margin=dict(l=0, r=200, t=50, b=0),
600
+ legend=dict(
601
+ orientation="v",
602
+ yanchor="top",
603
+ y=0.99,
604
+ xanchor="left",
605
+ x=1.02,
606
+ bgcolor='rgba(13, 17, 23, 0.9)',
607
+ bordercolor='#30363d',
608
+ borderwidth=1,
609
+ font=dict(size=11),
610
+ ),
611
+ )
612
+
613
+ return fig
614
+
615
+
616
+ def create_cluster_graph(dump_data, coords_2d, clusters, cluster_labels=None):
617
+ """Interactive Plotly visualization with cluster labels (Legacy - use create_knowledge_graph instead)"""
618
+
619
+ fig = go.Figure()
620
+
621
+ # Expanded color palette with 20 distinct colors
622
+ cluster_colors = [
623
+ '#FF6B6B', # Red
624
+ '#4ECDC4', # Teal
625
+ '#45B7D1', # Sky Blue
626
+ '#FFA07A', # Light Salmon
627
+ '#98D8C8', # Mint
628
+ '#F7DC6F', # Yellow
629
+ '#BB8FCE', # Purple
630
+ '#85C1E2', # Light Blue
631
+ '#F8B739', # Orange
632
+ '#52BE80', # Green
633
+ '#EC7063', # Coral
634
+ '#AF7AC5', # Lavender
635
+ '#5DADE2', # Ocean Blue
636
+ '#48C9B0', # Turquoise
637
+ '#F1948A', # Pink
638
+ '#85929E', # Gray Blue
639
+ '#F39C12', # Dark Orange
640
+ '#3498DB', # Bright Blue
641
+ '#E74C3C', # Bright Red
642
+ '#9B59B6' # Violet
643
+ ]
644
+
645
+ # Color map for clusters
646
+ unique_clusters = set(clusters)
647
+ colors = {-1: '#95A5A6'} # Gray for noise points
648
+
649
+ for i, c in enumerate([c for c in unique_clusters if c != -1]):
650
+ colors[c] = cluster_colors[i % len(cluster_colors)]
651
+
652
+ # Plot each cluster
653
+ for cluster_id in unique_clusters:
654
+ mask = clusters == cluster_id
655
+ cluster_coords = coords_2d[mask]
656
+ cluster_texts = [dump_data[i][1] for i in range(len(dump_data)) if clusters[i] == cluster_id]
657
+
658
+ # Get cluster label if available
659
+ if cluster_labels and cluster_id in cluster_labels:
660
+ label = cluster_labels[cluster_id]
661
+ else:
662
+ label = f"Cluster {cluster_id}" if cluster_id != -1 else "Unclustered"
663
+
664
+ fig.add_trace(go.Scatter(
665
+ x=cluster_coords[:, 0],
666
+ y=cluster_coords[:, 1],
667
+ mode='markers+text',
668
+ name=label,
669
+ marker=dict(
670
+ size=14, # Slightly larger for visibility
671
+ color=colors[cluster_id],
672
+ line=dict(width=2, color='white'), # Thicker white border
673
+ opacity=0.9 # Slight transparency to see overlaps
674
+ ),
675
+ text=[f"{i+1}" for i in range(len(cluster_texts))],
676
+ textposition="top center",
677
+ textfont=dict(size=10, color='white'),
678
+ hovertext=cluster_texts,
679
+ hoverinfo='text'
680
+ ))
681
+
682
+ fig.update_layout(
683
+ title="Brain Dump Sanctuary - Semantic Clusters",
684
+ showlegend=True,
685
+ hovermode='closest',
686
+ width=1200, # Wider for better separation
687
+ height=700, # Taller for better separation
688
+ plot_bgcolor='#1a1a1a', # Dark background
689
+ paper_bgcolor='#0d1117',
690
+ font=dict(color='white'),
691
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
692
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
693
+ legend=dict(
694
+ orientation="v",
695
+ yanchor="top",
696
+ y=1,
697
+ xanchor="left",
698
+ x=1.02,
699
+ bgcolor='rgba(13, 17, 23, 0.8)',
700
+ bordercolor='#30363d',
701
+ borderwidth=1
702
+ )
703
+ )
704
+
705
+ return fig
706
+
707
+
708
+ # ============== 5. DEMO PIPELINE ==============
709
+ def run_demo():
710
+ """Complete pipeline demo"""
711
+
712
+ # Sample brain dumps
713
+ sample_dumps = [
714
+ # Consciousness & Mind (8 items)
715
+ "Why do dreams feel so real but fade so quickly?",
716
+ "Is consciousness an emergent property?",
717
+ "Why do we forget things we just read?",
718
+ "What causes earworms (songs stuck in head)?",
719
+ "Why does time feel faster as we age?",
720
+ "How does anesthesia actually work?",
721
+ "What makes us self-aware?",
722
+ "Can machines ever truly be conscious?",
723
+
724
+ # Physics & Quantum (8 items)
725
+ "How does quantum entanglement actually work?",
726
+ "Is math discovered or invented?",
727
+ "What happens at the event horizon of a black hole?",
728
+ "Why is the speed of light constant?",
729
+ "What is dark matter made of?",
730
+ "How can particles be in two places at once?",
731
+ "What came before the Big Bang?",
732
+ "Why does time only move forward?",
733
+
734
+ # AI & Technology (8 items)
735
+ "Are LLMs actually understanding or just pattern matching?",
736
+ "How do neural networks learn representations?",
737
+ "What makes a question 'good' vs 'bad'?",
738
+ "Can AI ever be truly creative?",
739
+ "How do transformers attend to context?",
740
+ "What is the halting problem and why does it matter?",
741
+ "Will we ever achieve AGI?",
742
+ "How do computers generate random numbers?",
743
+
744
+ # Biology & Nature (8 items)
745
+ "How do birds navigate during migration?",
746
+ "What causes the smell of rain on dry ground?",
747
+ "What's the connection between gut bacteria and mood?",
748
+ "How do whales communicate across oceans?",
749
+ "Why do cats purr?",
750
+ "How do fireflies synchronize their flashing?",
751
+ "Why do we yawn when others yawn?",
752
+ "How do octopuses change color instantly?",
753
+
754
+ # Food & Chemistry (8 items)
755
+ "What makes sourdough bread different from regular bread?",
756
+ "Why does coffee smell better than it tastes?",
757
+ "What makes food spicy and why do we like it?",
758
+ "How does fermentation preserve food?",
759
+ "Why does chocolate melt at body temperature?",
760
+ "What causes that metallic taste when you bite foil?",
761
+ "Why do onions make us cry?",
762
+ "How do flavors combine to create umami?",
763
+
764
+ # Music & Art (8 items)
765
+ "Why do some songs give me chills?",
766
+ "What makes a melody memorable?",
767
+ "How does rhythm affect our emotions?",
768
+ "Why do major keys sound happy and minor keys sad?",
769
+ "What makes abstract art 'good'?",
770
+ "How does color theory influence mood?",
771
+ "Why do we find symmetry beautiful?",
772
+ "What is the golden ratio in design?",
773
+
774
+ # Psychology & Society (8 items)
775
+ "Why do we procrastinate even when we know better?",
776
+ "How does confirmation bias shape our beliefs?",
777
+ "What causes impostor syndrome?",
778
+ "Why are first impressions so lasting?",
779
+ "How do echo chambers form online?",
780
+ "What makes some ideas go viral?",
781
+ "Why is it hard to change someone's mind?",
782
+ "How does groupthink override individual judgment?",
783
+
784
+ # Language & Communication (4 items)
785
+ "Why do different languages have different sounds?",
786
+ "How did writing systems evolve independently?",
787
+ "What makes a joke funny across cultures?",
788
+ "Why do babies learn language so easily?"
789
+ ]
790
+
791
+ print("=== BRAIN DUMP SANCTUARY - DEMO ===\n")
792
+
793
+ # 1. Initialize
794
+ db = BrainDumpDB()
795
+ embedder = EmbeddingEngine()
796
+ clusterer = ClusterEngine(min_cluster_size=2)
797
+
798
+ # 2. Add dumps to database
799
+ print("\n[1/4] Adding brain dumps to database...")
800
+ for dump in sample_dumps:
801
+ dump_id, is_duplicate = db.add_dump(dump)
802
+ if is_duplicate:
803
+ print(f" Skipped duplicate: {dump[:50]}...")
804
+
805
+ # 3. Generate embeddings
806
+ print("\n[2/4] Generating embeddings (CPU)...")
807
+ dumps = db.get_all_dumps()
808
+ texts = [d[1] for d in dumps]
809
+ embeddings = embedder.embed(texts)
810
+
811
+ for i, (dump_id, text, _, created_at) in enumerate(dumps):
812
+ db.update_embedding(dump_id, embeddings[i])
813
+
814
+ # 4. Cluster
815
+ print("\n[3/5] Performing semantic clustering...")
816
+ clusters, coords_2d = clusterer.fit_predict(embeddings)
817
+
818
+ for i, (dump_id, _, _, _) in enumerate(dumps):
819
+ db.update_cluster(dump_id, clusters[i])
820
+
821
+ # 5. Auto-generate cluster labels using LangChain
822
+ print("\n[4/5] Generating cluster labels with LLM...")
823
+ cluster_labels = {}
824
+ unique_clusters = set(clusters) - {-1}
825
+
826
+ for cluster_id in unique_clusters:
827
+ cluster_dumps = [dumps[i][1] for i in range(len(dumps)) if clusters[i] == cluster_id]
828
+ label = clusterer.generate_cluster_label(cluster_dumps)
829
+ cluster_labels[cluster_id] = label
830
+ db.save_cluster_label(cluster_id, label)
831
+ print(f" Cluster {cluster_id}: '{label}'")
832
+
833
+ # 6. Visualize
834
+ print("\n[5/5] Creating knowledge graph visualization...")
835
+ fig = create_knowledge_graph(dumps, coords_2d, clusters, cluster_labels, embeddings)
836
+ fig.write_html("brain_dump_knowledge_graph.html")
837
+ print("βœ“ Saved to: brain_dump_knowledge_graph.html")
838
+
839
+ # 7. Show cluster insights
840
+ print("\n=== CLUSTER INSIGHTS ===")
841
+ for cluster_id in sorted(unique_clusters):
842
+ cluster_dumps = [dumps[i][1] for i in range(len(dumps)) if clusters[i] == cluster_id]
843
+ label = cluster_labels.get(cluster_id, f"Cluster {cluster_id}")
844
+ print(f"\n{label} ({len(cluster_dumps)} items):")
845
+ for dump in cluster_dumps[:3]: # Show first 3
846
+ print(f" - {dump}")
847
+
848
+
849
+ if __name__ == "__main__":
850
+ run_demo()
neo4j_maintenance.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Neo4j Database Maintenance Script
3
+ Use this to manage your Brain Dump Sanctuary Neo4j database
4
+ """
5
+
6
+ import os
7
+ from dotenv import load_dotenv
8
+ from braindump_core import BrainDumpDB
9
+
10
+ load_dotenv()
11
+
12
+ def clear_all_dumps():
13
+ """Clear all dumps from the database."""
14
+ db = BrainDumpDB()
15
+ with db.driver.session() as session:
16
+ # Delete all relationships and nodes
17
+ session.run("MATCH (d:Dump) DETACH DELETE d")
18
+ session.run("MATCH (c:Cluster) DETACH DELETE c")
19
+ print("βœ“ All dumps and clusters cleared from Neo4j")
20
+ db.close()
21
+
22
+ def show_database_stats():
23
+ """Show current database statistics."""
24
+ db = BrainDumpDB()
25
+ with db.driver.session() as session:
26
+ # Count dumps
27
+ result_dumps = session.run("MATCH (d:Dump) RETURN COUNT(d) as count")
28
+ dump_count = result_dumps.single()["count"]
29
+
30
+ # Count clusters
31
+ result_clusters = session.run("MATCH (c:Cluster) RETURN COUNT(c) as count")
32
+ cluster_count = result_clusters.single()["count"]
33
+
34
+ # Count relationships
35
+ result_rels = session.run("MATCH ()-[r:IN_CLUSTER]->() RETURN COUNT(r) as count")
36
+ rel_count = result_rels.single()["count"]
37
+
38
+ print("\n=== Neo4j Database Statistics ===")
39
+ print(f"Total Brain Dumps: {dump_count}")
40
+ print(f"Total Clusters: {cluster_count}")
41
+ print(f"Dump-to-Cluster Relationships: {rel_count}")
42
+
43
+ db.close()
44
+
45
+ def remove_duplicates():
46
+ """Remove duplicate brain dumps (same text)."""
47
+ db = BrainDumpDB()
48
+ with db.driver.session() as session:
49
+ # Find duplicates
50
+ result = session.run("""
51
+ MATCH (d:Dump)
52
+ WITH d.text as text, COLLECT(d.id) as ids
53
+ WHERE SIZE(ids) > 1
54
+ RETURN text, ids
55
+ """)
56
+
57
+ duplicates = list(result)
58
+ if not duplicates:
59
+ print("βœ“ No duplicates found!")
60
+ else:
61
+ print(f"Found {len(duplicates)} duplicate texts:")
62
+ for row in duplicates:
63
+ text = row["text"]
64
+ ids = row["ids"]
65
+ print(f" - '{text[:50]}...' appears {len(ids)} times")
66
+
67
+ # Keep first, delete rest
68
+ for dup_id in ids[1:]:
69
+ session.run("""
70
+ MATCH (d:Dump {id: $dump_id})
71
+ DETACH DELETE d
72
+ """, dump_id=dup_id)
73
+
74
+ print(f"βœ“ Removed duplicate entries")
75
+
76
+ db.close()
77
+
78
+ if __name__ == "__main__":
79
+ import sys
80
+
81
+ if len(sys.argv) < 2:
82
+ print("Usage: python neo4j_maintenance.py <command>")
83
+ print("\nCommands:")
84
+ print(" stats - Show database statistics")
85
+ print(" clear - Clear all dumps and clusters")
86
+ print(" dedup - Remove duplicate brain dumps")
87
+ sys.exit(1)
88
+
89
+ command = sys.argv[1]
90
+
91
+ if command == "stats":
92
+ show_database_stats()
93
+ elif command == "clear":
94
+ confirm = input("Are you sure? This will delete all data. (yes/no): ")
95
+ if confirm.lower() == "yes":
96
+ clear_all_dumps()
97
+ else:
98
+ print("Cancelled.")
99
+ elif command == "dedup":
100
+ remove_duplicates()
101
+ else:
102
+ print(f"Unknown command: {command}")
requirements.txt CHANGED
@@ -1,3 +1,31 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # requirements.txt
2
+ # Core ML/NLP
3
+ sentence-transformers
4
+ scikit-learn==1.3.2
5
+ hdbscan==0.8.33
6
+ umap-learn==0.5.5
7
+
8
+ # LLM & Agents
9
+ langgraph==0.2.0
10
+ langchain==0.2.0
11
+ langchain-google-genai
12
+
13
+ # Web Search
14
+ tavily-python
15
+ fuzzywuzzy[speedup]
16
+ requests
17
+
18
+ # Database - Graph DB (Neo4j)
19
+ neo4j==5.17.0
20
+
21
+ # Visualization & UI
22
+ streamlit==1.51.0
23
+ plotly==5.18.0
24
+
25
+ # Data
26
+ pandas==2.1.3
27
+ numpy==1.24.3
28
+ pytz
29
+
30
+ # Environment
31
+ python-dotenv
src/streamlit_app.py DELETED
@@ -1,40 +0,0 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_duplicate_detection.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script to verify duplicate detection functionality
3
+ """
4
+
5
+ from braindump_core import BrainDumpDB
6
+
7
+ def test_duplicate_detection():
8
+ """Test that duplicate brain dumps are detected and not re-added"""
9
+
10
+ print("=== Testing Duplicate Detection ===\n")
11
+
12
+ # Initialize database
13
+ db = BrainDumpDB()
14
+
15
+ # Test 1: Add a new dump
16
+ test_text = "Why do dreams feel so real but fade so quickly?"
17
+ print(f"Test 1: Adding new dump...")
18
+ dump_id1, is_duplicate1 = db.add_dump(test_text)
19
+ print(f" Result: dump_id={dump_id1}, is_duplicate={is_duplicate1}")
20
+ assert not is_duplicate1, "First addition should not be a duplicate"
21
+ print(" βœ“ PASS: New dump added successfully\n")
22
+
23
+ # Test 2: Try to add the same dump again
24
+ print(f"Test 2: Adding same dump again...")
25
+ dump_id2, is_duplicate2 = db.add_dump(test_text)
26
+ print(f" Result: dump_id={dump_id2}, is_duplicate={is_duplicate2}")
27
+ assert is_duplicate2, "Second addition should be detected as duplicate"
28
+ assert dump_id1 == dump_id2, "Should return the same dump_id"
29
+ print(" βœ“ PASS: Duplicate detected and prevented\n")
30
+
31
+ # Test 3: Add a different dump
32
+ different_text = "How does quantum entanglement actually work?"
33
+ print(f"Test 3: Adding different dump...")
34
+ dump_id3, is_duplicate3 = db.add_dump(different_text)
35
+ print(f" Result: dump_id={dump_id3}, is_duplicate={is_duplicate3}")
36
+ assert not is_duplicate3, "Different dump should not be a duplicate"
37
+ assert dump_id3 != dump_id1, "Should have a different dump_id"
38
+ print(" βœ“ PASS: Different dump added successfully\n")
39
+
40
+ # Test 4: Verify total count
41
+ all_dumps = db.get_all_dumps()
42
+ print(f"Test 4: Verifying database state...")
43
+ print(f" Total dumps in database: {len(all_dumps)}")
44
+ print(f" Expected at least 2 unique dumps")
45
+ # Note: We might have more dumps from previous runs
46
+ print(" βœ“ Database contains the dumps\n")
47
+
48
+ # Clean up
49
+ db.close()
50
+
51
+ print("=== All Tests Passed! ===")
52
+ print("\nDuplicate detection is working correctly:")
53
+ print(" βœ“ New dumps are added")
54
+ print(" βœ“ Duplicate dumps are detected")
55
+ print(" βœ“ Duplicate dumps return existing ID")
56
+ print(" βœ“ Different dumps are treated separately")
57
+
58
+ if __name__ == "__main__":
59
+ test_duplicate_detection()