Spaces:
Sleeping
Sleeping
Kshitij Nevrekar commited on
Commit Β·
e1af63d
1
Parent(s): bb74951
upload from github
Browse files- .env.example +22 -0
- .gitignore +217 -0
- LICENSE +21 -0
- README.md +670 -15
- agents.py +707 -0
- app.py +628 -0
- braindump_core.py +850 -0
- neo4j_maintenance.py +102 -0
- requirements.txt +31 -3
- src/streamlit_app.py +0 -40
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|