mirror of
https://github.com/perstarkse/minne.git
synced 2026-01-11 20:50:24 +01:00
Compare commits
8 Commits
benchmarks
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe4ac9fec | ||
|
|
db43be1606 | ||
|
|
8e8370b080 | ||
|
|
84695fa0cc | ||
|
|
654add98bc | ||
|
|
244ec0ea25 | ||
|
|
d8416ac711 | ||
|
|
f9f48d1046 |
@@ -1,8 +1,9 @@
|
||||
# Changelog
|
||||
## Unreleased
|
||||
## 1.0.0 (2026-01-02)
|
||||
- **Locally generated embeddings are now default**. If you want to continue using API embeddings, set EMBEDDING_BACKEND to openai. This will download a ONNX model and recreate all embeddings. But in most instances it's very worth it. Removing the network bound call to create embeddings. Creating embeddings on my N100 device is extremely fast. Typically a search response is provided in less than 50ms.
|
||||
- Added a benchmarks create for evaluating the retrieval process
|
||||
- Added fastembed embedding support, enables the use of local CPU generated embeddings, greatly improved latency if machine can handle it. Quick search has vastly better accuracy and is much faster, 50ms latency when testing compared to minimum 300ms.
|
||||
- Embeddings stored on own table
|
||||
- Embeddings stored on own table.
|
||||
- Refactored retrieval pipeline to use the new, faster and more accurate strategy. Read [blog post](https://blog.stark.pub/posts/eval-retrieval-refactor/) for more details.
|
||||
|
||||
## Version 0.2.7 (2025-12-04)
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3820,7 +3820,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
||||
|
||||
[[package]]
|
||||
name = "main"
|
||||
version = "0.2.7"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"api-router",
|
||||
|
||||
261
README.md
261
README.md
@@ -1,265 +1,66 @@
|
||||
# Minne - A Graph-Powered Personal Knowledge Base
|
||||
# Minne
|
||||
|
||||
**Minne (Swedish for "memory")** is a personal knowledge management system and save-for-later application for capturing, organizing, and accessing your information. Inspired by the Zettelkasten method, it uses a graph database to automatically create connections between your notes without manual linking overhead.
|
||||
**A graph-powered personal knowledge base that makes storing easy.**
|
||||
|
||||
Capture content effortlessly, let AI discover connections, and explore your knowledge visually. Self-hosted and privacy-focused.
|
||||
|
||||
[](https://github.com/perstarkse/minne/actions/workflows/release.yml)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://github.com/perstarkse/minne/releases/latest)
|
||||
|
||||

|
||||

|
||||
|
||||
## Demo deployment
|
||||
## Try It
|
||||
|
||||
To test _Minne_ out, enter [this](https://minne-demo.stark.pub) read-only demo deployment to view and test functionality out.
|
||||
|
||||
## Noteworthy Features
|
||||
|
||||
- **Search & Chat Interface** - Find content or knowledge instantly with full-text search, or use the chat mode and conversational AI to find and reason about content
|
||||
- **Manual and AI-assisted connections** - Build entities and relationships manually with full control, let AI create entities and relationships automatically, or blend both approaches with AI suggestions for manual approval
|
||||
- **Hybrid Retrieval System** - Search combining vector similarity, full-text search, and graph traversal for highly relevant results
|
||||
- **Scratchpad Feature** - Quickly capture thoughts and convert them to permanent content when ready
|
||||
- **Visual Graph Explorer** - Interactive D3-based navigation of your knowledge entities and connections
|
||||
- **Multi-Format Support** - Ingest text, URLs, PDFs, audio files, and images into your knowledge base
|
||||
- **Performance Focus** - Built with Rust and server-side rendering for speed and efficiency
|
||||
- **Self-Hosted & Privacy-Focused** - Full control over your data, and compatible with any OpenAI-compatible API that supports structured outputs
|
||||
|
||||
## The "Why" Behind Minne
|
||||
|
||||
For a while I've been fascinated by personal knowledge management systems. I wanted something that made it incredibly easy to capture content - snippets of text, URLs, and other media - while automatically discovering connections between ideas. But I also wanted to maintain control over my knowledge structure.
|
||||
|
||||
Traditional tools like Logseq and Obsidian are excellent, but the manual linking process often became a hindrance. Meanwhile, fully automated systems sometimes miss important context or create relationships I wouldn't have chosen myself.
|
||||
|
||||
So I built Minne to offer the best of both worlds: effortless content capture with AI-assisted relationship discovery, but with the flexibility to manually curate, edit, or override any connections. You can let AI handle the heavy lifting of extracting entities and finding relationships, take full control yourself, or use a hybrid approach where AI suggests connections that you can approve or modify.
|
||||
|
||||
While developing Minne, I discovered [KaraKeep](https://github.com/karakeep-app/karakeep) (formerly Hoarder), which is an excellent application in a similar space – you probably want to check it out! However, if you're interested in a PKM that offers both intelligent automation and manual curation, with the ability to chat with your knowledge base, then Minne might be worth testing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Features in Detail](#features-in-detail)
|
||||
- [Configuration](#configuration)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Application Architecture](#application-architecture)
|
||||
- [AI Configuration](#ai-configuration--model-selection)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Development](#development)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
**[Live Demo](https://minne-demo.stark.pub)** — Read-only demo deployment
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get Minne running is with Docker Compose:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/perstarkse/minne.git
|
||||
cd minne
|
||||
|
||||
# Start Minne and its database
|
||||
# Set your OpenAI API key in docker-compose.yml, then:
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
# Open http://localhost:3000
|
||||
```
|
||||
|
||||
**Required Setup:**
|
||||
- Replace `your_openai_api_key_here` in `docker-compose.yml` with your actual API key
|
||||
- Configure `OPENAI_BASE_URL` if using a custom AI provider (like Ollama)
|
||||
|
||||
For detailed installation options, see [Configuration](#configuration).
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Search vs. Chat mode
|
||||
|
||||
**Search** - Use when you know roughly what you're looking for. Full-text search finds items quickly by matching your query terms.
|
||||
|
||||
**Chat Mode** - Use when you want to explore concepts, find connections, or reason about your knowledge. The AI analyzes your query and finds relevant context across your entire knowledge base.
|
||||
|
||||
### Content Processing
|
||||
|
||||
Minne automatically processes content you save:
|
||||
1. **Web scraping** extracts readable text from URLs
|
||||
2. **Text analysis** identifies key concepts and relationships
|
||||
3. **Graph creation** builds connections between related content
|
||||
4. **Embedding generation** enables semantic search capabilities
|
||||
|
||||
### Visual Knowledge Graph
|
||||
|
||||
Explore your knowledge as an interactive network with flexible curation options:
|
||||
|
||||
**Manual Curation** - Create knowledge entities and relationships yourself with full control over your graph structure
|
||||
|
||||
**AI Automation** - Let AI automatically extract entities and discover relationships from your content
|
||||
|
||||
**Hybrid Approach** - Get AI-suggested relationships and entities that you can manually review, edit, or approve
|
||||
|
||||
The graph visualization shows:
|
||||
- Knowledge entities as nodes (manually created or AI-extracted)
|
||||
- Relationships as connections (manually defined, AI-discovered, or suggested)
|
||||
- Interactive navigation for discovery and editing
|
||||
|
||||
### Optional FastEmbed Reranking
|
||||
|
||||
Minne ships with an opt-in reranking stage powered by [fastembed-rs](https://github.com/Anush008/fastembed-rs). When enabled, the hybrid retrieval results are rescored with a lightweight cross-encoder before being returned to chat or ingestion flows. In practice this often means more relevant results, boosting answer quality and downstream enrichment.
|
||||
|
||||
⚠️ **Resource notes**
|
||||
- Enabling reranking downloads and caches ~1.1 GB of model data on first startup (cached under `<data_dir>/fastembed/reranker` by default).
|
||||
- Initialization takes longer while warming the cache, and each query consumes extra CPU. The default pool size (2) is tuned for a singe user setup, but could work with a pool size on 1 as well.
|
||||
- The feature is disabled by default. Set `reranking_enabled: true` (or `RERANKING_ENABLED=true`) if you’re comfortable with the additional footprint.
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
reranking_enabled: true
|
||||
reranking_pool_size: 2
|
||||
fastembed_cache_dir: "/var/lib/minne/fastembed" # optional override, defaults to .fastembed_cache
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Rust with Axum framework and Server-Side Rendering (SSR)
|
||||
- **Frontend:** HTML with HTMX and minimal JavaScript for interactivity
|
||||
- **Database:** SurrealDB (graph, document, and vector search)
|
||||
- **AI Integration:** OpenAI-compatible API with structured outputs
|
||||
- **Web Processing:** Headless Chrome for robust webpage content extraction
|
||||
|
||||
## Configuration
|
||||
|
||||
Minne can be configured using environment variables or a `config.yaml` file. Environment variables take precedence over `config.yaml`.
|
||||
|
||||
### Required Configuration
|
||||
|
||||
- `SURREALDB_ADDRESS`: WebSocket address of your SurrealDB instance (e.g., `ws://127.0.0.1:8000`)
|
||||
- `SURREALDB_USERNAME`: Username for SurrealDB (e.g., `root_user`)
|
||||
- `SURREALDB_PASSWORD`: Password for SurrealDB (e.g., `root_password`)
|
||||
- `SURREALDB_DATABASE`: Database name in SurrealDB (e.g., `minne_db`)
|
||||
- `SURREALDB_NAMESPACE`: Namespace in SurrealDB (e.g., `minne_ns`)
|
||||
- `OPENAI_API_KEY`: Your API key for OpenAI compatible endpoint
|
||||
- `HTTP_PORT`: Port for the Minne server (Default: `3000`)
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
- `RUST_LOG`: Controls logging level (e.g., `minne=info,tower_http=debug`)
|
||||
- `DATA_DIR`: Directory to store local data (e.g., `./data`)
|
||||
- `OPENAI_BASE_URL`: Base URL for custom AI providers (like Ollama)
|
||||
- `RERANKING_ENABLED` / `reranking_enabled`: Set to `true` to enable the FastEmbed reranking stage (default `false`)
|
||||
- `RERANKING_POOL_SIZE` / `reranking_pool_size`: Maximum concurrent reranker workers (defaults to `2`)
|
||||
- `FASTEMBED_CACHE_DIR` / `fastembed_cache_dir`: Directory for cached FastEmbed models (defaults to `<data_dir>/fastembed/reranker`)
|
||||
- `FASTEMBED_SHOW_DOWNLOAD_PROGRESS` / `fastembed_show_download_progress`: Show model download progress when warming the cache (default `true`)
|
||||
|
||||
### Example config.yaml
|
||||
|
||||
```yaml
|
||||
surrealdb_address: "ws://127.0.0.1:8000"
|
||||
surrealdb_username: "root_user"
|
||||
surrealdb_password: "root_password"
|
||||
surrealdb_database: "minne_db"
|
||||
surrealdb_namespace: "minne_ns"
|
||||
openai_api_key: "sk-YourActualOpenAIKeyGoesHere"
|
||||
data_dir: "./minne_app_data"
|
||||
http_port: 3000
|
||||
# rust_log: "info"
|
||||
```
|
||||
|
||||
## Installation Options
|
||||
|
||||
### 1. Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone and run
|
||||
git clone https://github.com/perstarkse/minne.git
|
||||
cd minne
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The included `docker-compose.yml` handles SurrealDB and Chromium dependencies automatically.
|
||||
|
||||
### 2. Nix
|
||||
Or with Nix (with environment variables set):
|
||||
|
||||
```bash
|
||||
nix run 'github:perstarkse/minne#main'
|
||||
```
|
||||
|
||||
This fetches Minne and all dependencies, including Chromium.
|
||||
Pre-built binaries for Windows, macOS, and Linux are available on the [Releases](https://github.com/perstarkse/minne/releases/latest) page.
|
||||
|
||||
### 3. Pre-built Binaries
|
||||
## Features
|
||||
|
||||
Download binaries for Windows, macOS, and Linux from the [GitHub Releases](https://github.com/perstarkse/minne/releases/latest).
|
||||
- **Fast** — Rust backend with server-side rendering and HTMX for snappy interactions
|
||||
- **Search & Chat** — Search or use conversational AI to find and reason about content
|
||||
- **Knowledge Graph** — Visual exploration with automatic or manual relationship curation
|
||||
- **Hybrid Retrieval** — Vector similarity + full-text for relevant results
|
||||
- **Multi-Format** — Ingest text, URLs, PDFs, audio, and images
|
||||
- **Self-Hosted** — Your data, your server, any OpenAI-compatible API
|
||||
|
||||
**Requirements:** You'll need to provide SurrealDB and Chromium separately.
|
||||
## Documentation
|
||||
|
||||
### 4. Build from Source
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Installation](docs/installation.md) | Docker, Nix, binaries, source builds |
|
||||
| [Configuration](docs/configuration.md) | Environment variables, config.yaml, AI setup |
|
||||
| [Features](docs/features.md) | Search, Chat, Graph, Reranking, Ingestion |
|
||||
| [Architecture](docs/architecture.md) | Tech stack, crate structure, data flow |
|
||||
| [Vision](docs/vision.md) | Philosophy, roadmap, related projects |
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perstarkse/minne.git
|
||||
cd minne
|
||||
cargo run --release --bin main
|
||||
```
|
||||
## Tech Stack
|
||||
|
||||
**Requirements:** SurrealDB and Chromium must be installed and accessible in your PATH.
|
||||
|
||||
## Application Architecture
|
||||
|
||||
Minne offers flexible deployment options:
|
||||
|
||||
- **`main`**: Combined server and worker in one process (recommended for most users)
|
||||
- **`server`**: Web interface and API only
|
||||
- **`worker`**: Background processing only (for resource optimization)
|
||||
|
||||
## Usage
|
||||
|
||||
Once Minne is running at `http://localhost:3000`:
|
||||
|
||||
1. **Web Interface**: Full-featured experience for desktop and mobile
|
||||
2. **iOS Shortcut**: Use the [Minne iOS Shortcut](https://www.icloud.com/shortcuts/e433fbd7602f4e2eaa70dca162323477) for quick content capture
|
||||
3. **Content Types**: Save notes, URLs, audio files, and more
|
||||
4. **Knowledge Graph**: Explore automatic connections between your content
|
||||
5. **Chat Interface**: Query your knowledge base conversationally
|
||||
|
||||
## AI Configuration & Model Selection
|
||||
|
||||
### Setting Up AI Providers
|
||||
|
||||
Minne uses OpenAI-compatible APIs. Configure via environment variables or `config.yaml`:
|
||||
|
||||
- `OPENAI_API_KEY` (required): Your API key
|
||||
- `OPENAI_BASE_URL` (optional): Custom provider URL (e.g., Ollama: `http://localhost:11434/v1`)
|
||||
|
||||
### Model Selection
|
||||
|
||||
1. Access the `/admin` page in your Minne instance
|
||||
2. Select models for content processing and chat from your configured provider
|
||||
3. **Content Processing Requirements**: The model must support structured outputs
|
||||
4. **Embedding Dimensions**: Update this setting when changing embedding models (e.g., 1536 for `text-embedding-3-small`, 768 for `nomic-embed-text`)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Current development focus:
|
||||
|
||||
- TUI frontend with system editor integration
|
||||
- Enhanced reranking for improved retrieval recall
|
||||
- Additional content type support
|
||||
|
||||
Feature requests and contributions are welcome!
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Development build
|
||||
cargo build
|
||||
|
||||
# Comprehensive linting
|
||||
cargo clippy --workspace --all-targets --all-features
|
||||
```
|
||||
|
||||
The codebase includes extensive unit tests. Integration tests and additional contributions are welcome.
|
||||
Rust • Axum • HTMX • SurrealDB • FastEmbed
|
||||
|
||||
## Contributing
|
||||
I've developed Minne primarily for my own use, but having been in the selfhosted space for a long time, and using the efforts by others, I thought I'd share with the community. Feature requests are welcome.
|
||||
|
||||
Feature requests and contributions welcome. See [Vision](docs/vision.md) for roadmap.
|
||||
|
||||
## License
|
||||
|
||||
Minne is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See the [LICENSE](LICENSE) file for details.
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
@@ -62,9 +62,36 @@ DEFINE TABLE OVERWRITE conversation SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE file SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE knowledge_entity SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE message SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL TYPE RELATION;
|
||||
DEFINE FIELD IF NOT EXISTS in ON relates_to TYPE record<knowledge_entity>;
|
||||
DEFINE FIELD IF NOT EXISTS out ON relates_to TYPE record<knowledge_entity>;
|
||||
DEFINE FIELD IF NOT EXISTS metadata ON relates_to TYPE object;
|
||||
DEFINE FIELD IF NOT EXISTS metadata.user_id ON relates_to TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS metadata.source_id ON relates_to TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS metadata.relationship_type ON relates_to TYPE string;
|
||||
DEFINE TABLE OVERWRITE scratchpad SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE system_settings SCHEMAFULL;
|
||||
DEFINE TABLE OVERWRITE text_chunk SCHEMAFULL;
|
||||
-- text_content must have fields defined before enforcing SCHEMAFULL
|
||||
DEFINE TABLE OVERWRITE text_content SCHEMAFULL;
|
||||
DEFINE FIELD IF NOT EXISTS created_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS updated_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS text ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info ON text_content TYPE option<object>;
|
||||
DEFINE FIELD IF NOT EXISTS url_info ON text_content TYPE option<object>;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.url ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.title ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.image_id ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS context ON text_content TYPE option<string>;
|
||||
DEFINE FIELD IF NOT EXISTS category ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS user_id ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.id ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.created_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.updated_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.sha256 ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.path ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.file_name ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.mime_type ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.user_id ON text_content TYPE string;
|
||||
|
||||
DEFINE TABLE OVERWRITE user SCHEMAFULL;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schemas":"--- original\n+++ modified\n@@ -242,7 +242,7 @@\n\n # Defines the schema for the 'text_content' table.\n\n-DEFINE TABLE IF NOT EXISTS text_content SCHEMALESS;\n+DEFINE TABLE IF NOT EXISTS text_content SCHEMAFULL;\n\n # Standard fields\n DEFINE FIELD IF NOT EXISTS created_at ON text_content TYPE datetime;\n@@ -254,10 +254,24 @@\n DEFINE FIELD IF NOT EXISTS file_info ON text_content TYPE option<object>;\n # UrlInfo is a struct, store as object\n DEFINE FIELD IF NOT EXISTS url_info ON text_content TYPE option<object>;\n+DEFINE FIELD IF NOT EXISTS url_info.url ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS url_info.title ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS url_info.image_id ON text_content TYPE string;\n+\n DEFINE FIELD IF NOT EXISTS context ON text_content TYPE option<string>;\n DEFINE FIELD IF NOT EXISTS category ON text_content TYPE string;\n DEFINE FIELD IF NOT EXISTS user_id ON text_content TYPE string;\n\n+# FileInfo fields\n+DEFINE FIELD IF NOT EXISTS file_info.id ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS file_info.created_at ON text_content TYPE datetime;\n+DEFINE FIELD IF NOT EXISTS file_info.updated_at ON text_content TYPE datetime;\n+DEFINE FIELD IF NOT EXISTS file_info.sha256 ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS file_info.path ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS file_info.file_name ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS file_info.mime_type ON text_content TYPE string;\n+DEFINE FIELD IF NOT EXISTS file_info.user_id ON text_content TYPE string;\n+\n # Indexes based on query patterns\n DEFINE INDEX IF NOT EXISTS text_content_user_id_idx ON text_content FIELDS user_id;\n DEFINE INDEX IF NOT EXISTS text_content_created_at_idx ON text_content FIELDS created_at;\n","events":null}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Defines the schema for the 'text_content' table.
|
||||
|
||||
DEFINE TABLE IF NOT EXISTS text_content SCHEMALESS;
|
||||
DEFINE TABLE IF NOT EXISTS text_content SCHEMAFULL;
|
||||
|
||||
# Standard fields
|
||||
DEFINE FIELD IF NOT EXISTS created_at ON text_content TYPE datetime;
|
||||
@@ -12,10 +12,24 @@ DEFINE FIELD IF NOT EXISTS text ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info ON text_content TYPE option<object>;
|
||||
# UrlInfo is a struct, store as object
|
||||
DEFINE FIELD IF NOT EXISTS url_info ON text_content TYPE option<object>;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.url ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.title ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS url_info.image_id ON text_content TYPE string;
|
||||
|
||||
DEFINE FIELD IF NOT EXISTS context ON text_content TYPE option<string>;
|
||||
DEFINE FIELD IF NOT EXISTS category ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS user_id ON text_content TYPE string;
|
||||
|
||||
# FileInfo fields
|
||||
DEFINE FIELD IF NOT EXISTS file_info.id ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.created_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.updated_at ON text_content TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.sha256 ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.path ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.file_name ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.mime_type ON text_content TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS file_info.user_id ON text_content TYPE string;
|
||||
|
||||
# Indexes based on query patterns
|
||||
DEFINE INDEX IF NOT EXISTS text_content_user_id_idx ON text_content FIELDS user_id;
|
||||
DEFINE INDEX IF NOT EXISTS text_content_created_at_idx ON text_content FIELDS created_at;
|
||||
|
||||
@@ -208,7 +208,26 @@ async fn ensure_runtime_indexes_inner(
|
||||
)
|
||||
.await
|
||||
}
|
||||
HnswIndexState::Matches => Ok(()),
|
||||
HnswIndexState::Matches => {
|
||||
let status = get_index_status(db, spec.index_name, spec.table).await?;
|
||||
if status.eq_ignore_ascii_case("error") {
|
||||
warn!(
|
||||
index = spec.index_name,
|
||||
table = spec.table,
|
||||
"HNSW index found in error state; triggering rebuild"
|
||||
);
|
||||
create_index_with_polling(
|
||||
db,
|
||||
spec.definition_overwrite(embedding_dimension),
|
||||
spec.index_name,
|
||||
spec.table,
|
||||
Some(spec.table),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
HnswIndexState::Different(existing) => {
|
||||
info!(
|
||||
index = spec.index_name,
|
||||
@@ -234,6 +253,30 @@ async fn ensure_runtime_indexes_inner(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_index_status(db: &SurrealDbClient, index_name: &str, table: &str) -> Result<String> {
|
||||
let info_query = format!("INFO FOR INDEX {index_name} ON TABLE {table};");
|
||||
let mut info_res = db
|
||||
.client
|
||||
.query(info_query)
|
||||
.await
|
||||
.context("checking index status")?;
|
||||
let info: Option<Value> = info_res.take(0).context("failed to take info result")?;
|
||||
|
||||
let info = match info {
|
||||
Some(i) => i,
|
||||
None => return Ok("unknown".to_string()),
|
||||
};
|
||||
|
||||
let building = info.get("building");
|
||||
let status = building
|
||||
.and_then(|b| b.get("status"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("ready")
|
||||
.to_string();
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
async fn rebuild_indexes_inner(db: &SurrealDbClient) -> Result<()> {
|
||||
debug!("Rebuilding indexes with concurrent definitions");
|
||||
create_fts_analyzer(db).await?;
|
||||
|
||||
@@ -3,7 +3,10 @@ use bytes;
|
||||
use mime_guess::from_path;
|
||||
use object_store::Error as ObjectStoreError;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::{BufReader, Read}, path::Path};
|
||||
use std::{
|
||||
io::{BufReader, Read},
|
||||
path::Path,
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use thiserror::Error;
|
||||
use tokio::task;
|
||||
|
||||
@@ -460,7 +460,11 @@ impl KnowledgeEntity {
|
||||
|
||||
for (i, entity) in all_entities.iter().enumerate() {
|
||||
if i > 0 && i % 100 == 0 {
|
||||
info!(progress = i, total = total_entities, "Re-embedding progress");
|
||||
info!(
|
||||
progress = i,
|
||||
total = total_entities,
|
||||
"Re-embedding progress"
|
||||
);
|
||||
}
|
||||
|
||||
let embedding_input = format!(
|
||||
@@ -485,6 +489,32 @@ impl KnowledgeEntity {
|
||||
new_embeddings.insert(entity.id.clone(), (embedding, entity.user_id.clone()));
|
||||
}
|
||||
info!("Successfully generated all new embeddings.");
|
||||
info!("Successfully generated all new embeddings.");
|
||||
|
||||
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
||||
info!("Removing old index and clearing embeddings...");
|
||||
|
||||
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
||||
// when we delete/replace data, dealing with a known SurrealDB panic.
|
||||
db.client
|
||||
.query(format!(
|
||||
"REMOVE INDEX idx_embedding_knowledge_entity_embedding ON TABLE {};",
|
||||
KnowledgeEntityEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
db.client
|
||||
.query(format!(
|
||||
"DELETE FROM {};",
|
||||
KnowledgeEntityEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
// Perform DB updates in a single transaction
|
||||
info!("Applying embedding updates in a transaction...");
|
||||
@@ -500,11 +530,11 @@ impl KnowledgeEntity {
|
||||
.join(",")
|
||||
);
|
||||
transaction_query.push_str(&format!(
|
||||
"UPSERT type::thing('knowledge_entity_embedding', '{id}') SET \
|
||||
"CREATE type::thing('knowledge_entity_embedding', '{id}') SET \
|
||||
entity_id = type::thing('knowledge_entity', '{id}'), \
|
||||
embedding = {embedding}, \
|
||||
user_id = '{user_id}', \
|
||||
created_at = IF created_at != NONE THEN created_at ELSE time::now() END, \
|
||||
created_at = time::now(), \
|
||||
updated_at = time::now();",
|
||||
id = id,
|
||||
embedding = embedding_str,
|
||||
@@ -520,7 +550,12 @@ impl KnowledgeEntity {
|
||||
transaction_query.push_str("COMMIT TRANSACTION;");
|
||||
|
||||
// Execute the entire atomic operation
|
||||
db.query(transaction_query).await?;
|
||||
db.client
|
||||
.query(transaction_query)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
info!("Re-embedding process for knowledge entities completed successfully.");
|
||||
Ok(())
|
||||
|
||||
@@ -55,7 +55,7 @@ impl KnowledgeRelationship {
|
||||
relationship_type = self.metadata.relationship_type.as_str()
|
||||
);
|
||||
|
||||
db_client.query(query).await?;
|
||||
db_client.query(query).await?.check()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,9 +99,7 @@ impl KnowledgeRelationship {
|
||||
Err(AppError::NotFound(format!("Relationship {id} not found")))
|
||||
}
|
||||
} else {
|
||||
db_client
|
||||
.query(format!("DELETE relates_to:`{id}`"))
|
||||
.await?;
|
||||
db_client.query(format!("DELETE relates_to:`{id}`")).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -161,7 +159,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_relationship() {
|
||||
async fn test_store_and_verify_by_source_id() {
|
||||
// Setup in-memory database for testing
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
@@ -169,6 +167,10 @@ mod tests {
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
// Create two entities to relate
|
||||
let entity1_id = create_test_entity("Entity 1", &db).await;
|
||||
let entity2_id = create_test_entity("Entity 2", &db).await;
|
||||
@@ -209,7 +211,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_relationship_by_id() {
|
||||
async fn test_store_and_delete_relationship() {
|
||||
// Setup in-memory database for testing
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
@@ -234,7 +236,7 @@ mod tests {
|
||||
relationship_type,
|
||||
);
|
||||
|
||||
// Store the relationship
|
||||
// Store relationship
|
||||
relationship
|
||||
.store_relationship(&db)
|
||||
.await
|
||||
@@ -255,12 +257,12 @@ mod tests {
|
||||
"Relationship should exist before deletion"
|
||||
);
|
||||
|
||||
// Delete the relationship by ID
|
||||
// Delete relationship by ID
|
||||
KnowledgeRelationship::delete_relationship_by_id(&relationship.id, &user_id, &db)
|
||||
.await
|
||||
.expect("Failed to delete relationship by ID");
|
||||
|
||||
// Query to verify the relationship was deleted
|
||||
// Query to verify relationship was deleted
|
||||
let mut result = db
|
||||
.query(format!(
|
||||
"SELECT * FROM relates_to WHERE metadata.user_id = '{}' AND metadata.source_id = '{}'",
|
||||
@@ -270,7 +272,7 @@ mod tests {
|
||||
.expect("Query failed");
|
||||
let results: Vec<KnowledgeRelationship> = result.take(0).unwrap_or_default();
|
||||
|
||||
// Verify the relationship no longer exists
|
||||
// Verify relationship no longer exists
|
||||
assert!(results.is_empty(), "Relationship should be deleted");
|
||||
}
|
||||
|
||||
@@ -342,7 +344,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_relationships_by_source_id() {
|
||||
async fn test_store_relationship_exists() {
|
||||
// Setup in-memory database for testing
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
|
||||
@@ -116,7 +116,7 @@ macro_rules! stored_object {
|
||||
}
|
||||
|
||||
|
||||
$(#[$struct_attr])*
|
||||
$(#[$struct_attr])*
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct $name {
|
||||
#[serde(deserialize_with = "deserialize_flexible_id")]
|
||||
|
||||
@@ -352,12 +352,12 @@ impl TextChunk {
|
||||
// Generate all new embeddings in memory
|
||||
let mut new_embeddings: HashMap<String, (Vec<f32>, String, String)> = HashMap::new();
|
||||
info!("Generating new embeddings for all chunks...");
|
||||
|
||||
|
||||
for (i, chunk) in all_chunks.iter().enumerate() {
|
||||
if i > 0 && i % 100 == 0 {
|
||||
info!(progress = i, total = total_chunks, "Re-embedding progress");
|
||||
}
|
||||
|
||||
|
||||
let embedding = provider
|
||||
.embed(&chunk.chunk)
|
||||
.await
|
||||
@@ -379,6 +379,28 @@ impl TextChunk {
|
||||
}
|
||||
info!("Successfully generated all new embeddings.");
|
||||
|
||||
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
||||
info!("Removing old index and clearing embeddings...");
|
||||
|
||||
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
||||
// when we delete/replace data, dealing with a known SurrealDB panic.
|
||||
db.client
|
||||
.query(format!(
|
||||
"REMOVE INDEX idx_embedding_text_chunk_embedding ON TABLE {};",
|
||||
TextChunkEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
db.client
|
||||
.query(format!("DELETE FROM {};", TextChunkEmbedding::table_name()))
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
// Perform DB updates in a single transaction against the embedding table
|
||||
info!("Applying embedding updates in a transaction...");
|
||||
let mut transaction_query = String::from("BEGIN TRANSACTION;");
|
||||
@@ -394,12 +416,12 @@ impl TextChunk {
|
||||
);
|
||||
write!(
|
||||
&mut transaction_query,
|
||||
"UPSERT type::thing('text_chunk_embedding', '{id}') SET \
|
||||
"CREATE type::thing('text_chunk_embedding', '{id}') SET \
|
||||
chunk_id = type::thing('text_chunk', '{id}'), \
|
||||
source_id = '{source_id}', \
|
||||
embedding = {embedding}, \
|
||||
user_id = '{user_id}', \
|
||||
created_at = IF created_at != NONE THEN created_at ELSE time::now() END, \
|
||||
created_at = time::now(), \
|
||||
updated_at = time::now();",
|
||||
id = id,
|
||||
embedding = embedding_str,
|
||||
@@ -418,7 +440,12 @@ impl TextChunk {
|
||||
|
||||
transaction_query.push_str("COMMIT TRANSACTION;");
|
||||
|
||||
db.query(transaction_query).await?;
|
||||
db.client
|
||||
.query(transaction_query)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.check()
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
info!("Re-embedding process for text chunks completed successfully.");
|
||||
Ok(())
|
||||
|
||||
@@ -250,9 +250,8 @@ impl EmbeddingProvider {
|
||||
|
||||
match config.embedding_backend {
|
||||
EmbeddingBackend::OpenAI => {
|
||||
let client = openai_client.ok_or_else(|| {
|
||||
anyhow!("OpenAI embedding backend requires an OpenAI client")
|
||||
})?;
|
||||
let client = openai_client
|
||||
.ok_or_else(|| anyhow!("OpenAI embedding backend requires an OpenAI client"))?;
|
||||
// Use defaults that match SystemSettings initial values
|
||||
Self::new_openai(client, "text-embedding-3-small".to_string(), 1536)
|
||||
}
|
||||
|
||||
74
docs/architecture.md
Normal file
74
docs/architecture.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Architecture
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Backend | Rust with Axum (SSR) |
|
||||
| Frontend | HTML + HTMX + minimal JS |
|
||||
| Database | SurrealDB (graph, document, vector) |
|
||||
| AI | OpenAI-compatible API |
|
||||
| Web Processing | Headless Chromium |
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
minne/
|
||||
├── main/ # Combined server + worker binary
|
||||
├── api-router/ # REST API routes
|
||||
├── html-router/ # SSR web interface
|
||||
├── ingestion-pipeline/ # Content processing pipeline
|
||||
├── retrieval-pipeline/ # Search and retrieval logic
|
||||
├── common/ # Shared types, storage, utilities
|
||||
├── evaluations/ # Benchmarking framework
|
||||
└── json-stream-parser/ # Streaming JSON utilities
|
||||
```
|
||||
|
||||
## Process Modes
|
||||
|
||||
| Binary | Purpose |
|
||||
|--------|---------|
|
||||
| `main` | All-in-one: serves UI and processes content |
|
||||
| `server` | UI and API only (no background processing) |
|
||||
| `worker` | Background processing only (no UI) |
|
||||
|
||||
Split deployment is useful for scaling or resource isolation.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Content In → Ingestion Pipeline → SurrealDB
|
||||
↓
|
||||
Entity Extraction
|
||||
↓
|
||||
Embedding Generation
|
||||
↓
|
||||
Graph Relationships
|
||||
|
||||
Query → Retrieval Pipeline → Results
|
||||
↓
|
||||
Vector Search + FTS
|
||||
↓
|
||||
RRF Fusion → (Optional Rerank) → Response
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
SurrealDB stores:
|
||||
|
||||
- **TextContent** — Raw ingested content
|
||||
- **TextChunk** — Chunked content with embeddings
|
||||
- **KnowledgeEntity** — Extracted entities (people, concepts, etc.)
|
||||
- **KnowledgeRelationship** — Connections between entities
|
||||
- **User** — Authentication and preferences
|
||||
- **SystemSettings** — Model configuration
|
||||
|
||||
Embeddings are stored in dedicated tables with HNSW indexes for fast vector search.
|
||||
|
||||
## Retrieval Strategy
|
||||
|
||||
1. **Collect candidates** — Vector similarity + full-text search
|
||||
2. **Merge ranks** — Reciprocal Rank Fusion (RRF)
|
||||
3. **Attach context** — Link chunks to parent entities
|
||||
4. **Rerank** (optional) — Cross-encoder reranking
|
||||
5. **Return** — Top-k results with metadata
|
||||
89
docs/configuration.md
Normal file
89
docs/configuration.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Configuration
|
||||
|
||||
Minne can be configured via environment variables or a `config.yaml` file. Environment variables take precedence.
|
||||
|
||||
## Required Settings
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `OPENAI_API_KEY` | API key for OpenAI-compatible endpoint | `sk-...` |
|
||||
| `SURREALDB_ADDRESS` | WebSocket address of SurrealDB | `ws://127.0.0.1:8000` |
|
||||
| `SURREALDB_USERNAME` | SurrealDB username | `root_user` |
|
||||
| `SURREALDB_PASSWORD` | SurrealDB password | `root_password` |
|
||||
| `SURREALDB_DATABASE` | Database name | `minne_db` |
|
||||
| `SURREALDB_NAMESPACE` | Namespace | `minne_ns` |
|
||||
|
||||
|
||||
## Optional Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HTTP_PORT` | Server port | `3000` |
|
||||
| `DATA_DIR` | Local data directory | `./data` |
|
||||
| `OPENAI_BASE_URL` | Custom AI provider URL | OpenAI default |
|
||||
| `RUST_LOG` | Logging level | `info` |
|
||||
| `STORAGE` | Storage backend (`local`, `memory`) | `local` |
|
||||
| `PDF_INGEST_MODE` | PDF ingestion strategy (`classic`, `llm-first`) | `llm-first` |
|
||||
| `RETRIEVAL_STRATEGY` | Default retrieval strategy | - |
|
||||
| `EMBEDDING_BACKEND` | Embedding provider (`openai`, `fastembed`) | `fastembed` |
|
||||
| `FASTEMBED_CACHE_DIR` | Model cache directory | `<data_dir>/fastembed` |
|
||||
| `FASTEMBED_SHOW_DOWNLOAD_PROGRESS` | Show progress bar for model downloads | `false` |
|
||||
| `FASTEMBED_MAX_LENGTH` | Max sequence length for FastEmbed models | - |
|
||||
|
||||
### Reranking (Optional)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `RERANKING_ENABLED` | Enable FastEmbed reranking | `false` |
|
||||
| `RERANKING_POOL_SIZE` | Concurrent reranker workers | - |
|
||||
|
||||
> [!NOTE]
|
||||
> Enabling reranking downloads ~1.1 GB of model data on first startup.
|
||||
|
||||
## Example config.yaml
|
||||
|
||||
```yaml
|
||||
surrealdb_address: "ws://127.0.0.1:8000"
|
||||
surrealdb_username: "root_user"
|
||||
surrealdb_password: "root_password"
|
||||
surrealdb_database: "minne_db"
|
||||
surrealdb_namespace: "minne_ns"
|
||||
openai_api_key: "sk-your-key-here"
|
||||
data_dir: "./minne_data"
|
||||
http_port: 3000
|
||||
|
||||
# New settings
|
||||
storage: "local"
|
||||
pdf_ingest_mode: "llm-first"
|
||||
embedding_backend: "fastembed"
|
||||
|
||||
# Optional reranking
|
||||
reranking_enabled: true
|
||||
reranking_pool_size: 2
|
||||
```
|
||||
|
||||
## AI Provider Setup
|
||||
|
||||
Minne works with any OpenAI-compatible API that supports structured outputs.
|
||||
|
||||
### OpenAI (Default)
|
||||
|
||||
Set `OPENAI_API_KEY` only. The default base URL points to OpenAI.
|
||||
|
||||
### Ollama
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY="ollama"
|
||||
OPENAI_BASE_URL="http://localhost:11434/v1"
|
||||
```
|
||||
|
||||
### Other Providers
|
||||
|
||||
Any provider exposing an OpenAI-compatible endpoint works. Set `OPENAI_BASE_URL` accordingly.
|
||||
|
||||
## Model Selection
|
||||
|
||||
1. Access `/admin` in your Minne instance
|
||||
2. Select models for content processing and chat
|
||||
3. **Content Processing**: Must support structured outputs
|
||||
4. **Embedding Dimensions**: Update when changing embedding models (e.g., 1536 for `text-embedding-3-small`)
|
||||
64
docs/features.md
Normal file
64
docs/features.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Features
|
||||
|
||||
## Search vs Chat
|
||||
|
||||
**Search** — Use when you know what you're looking for. Full-text search matches query terms across your content.
|
||||
|
||||
**Chat** — Use when exploring concepts or reasoning about your knowledge. The AI analyzes your query and retrieves relevant context from your entire knowledge base.
|
||||
|
||||
## Content Processing
|
||||
|
||||
Minne automatically processes saved content:
|
||||
|
||||
1. **Web scraping** extracts readable text from URLs (via headless Chrome)
|
||||
2. **Text analysis** identifies key concepts and relationships
|
||||
3. **Graph creation** builds connections between related content
|
||||
4. **Embedding generation** enables semantic search
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
Explore your knowledge as an interactive network:
|
||||
|
||||
- **Manual curation** — Create entities and relationships yourself
|
||||
- **AI automation** — Let AI extract entities and discover relationships
|
||||
- **Hybrid approach** — AI suggests connections for your approval
|
||||
|
||||
The D3-based graph visualization shows entities as nodes and relationships as edges.
|
||||
|
||||
## Hybrid Retrieval
|
||||
|
||||
Minne combines multiple retrieval strategies:
|
||||
|
||||
- **Vector similarity** — Semantic matching via embeddings
|
||||
- **Full-text search** — Keyword matching with BM25
|
||||
- **Graph traversal** — Following relationships between entities
|
||||
|
||||
Results are merged using Reciprocal Rank Fusion (RRF) for optimal relevance.
|
||||
|
||||
## Reranking (Optional)
|
||||
|
||||
When enabled, retrieval results are rescored with a cross-encoder model for improved relevance. Powered by [fastembed-rs](https://github.com/Anush008/fastembed-rs).
|
||||
|
||||
**Trade-offs:**
|
||||
- Downloads ~1.1 GB of model data
|
||||
- Adds latency per query
|
||||
- Potentially improves answer quality, see [blog post](https://blog.stark.pub/posts/eval-retrieval-refactor/)
|
||||
|
||||
Enable via `RERANKING_ENABLED=true`. See [Configuration](./configuration.md).
|
||||
|
||||
## Multi-Format Ingestion
|
||||
|
||||
Supported content types:
|
||||
- Plain text and notes
|
||||
- URLs (web pages)
|
||||
- PDF documents
|
||||
- Audio files
|
||||
- Images
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Quickly capture content without committing to permanent storage. Convert to full content when ready.
|
||||
|
||||
## iOS Shortcut
|
||||
|
||||
Use the [Minne iOS Shortcut](https://www.icloud.com/shortcuts/e433fbd7602f4e2eaa70dca162323477) for quick content capture from your phone.
|
||||
67
docs/installation.md
Normal file
67
docs/installation.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Installation
|
||||
|
||||
Minne can be installed through several methods. Choose the one that best fits your setup.
|
||||
|
||||
## Docker Compose (Recommended)
|
||||
|
||||
The fastest way to get Minne running with all dependencies:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perstarkse/minne.git
|
||||
cd minne
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The included `docker-compose.yml` handles SurrealDB and Chromium automatically.
|
||||
|
||||
**Required:** Set your `OPENAI_API_KEY` in `docker-compose.yml` before starting.
|
||||
|
||||
## Nix
|
||||
|
||||
Run Minne directly with Nix (includes Chromium):
|
||||
|
||||
```bash
|
||||
nix run 'github:perstarkse/minne#main'
|
||||
```
|
||||
|
||||
Configure via environment variables or a `config.yaml` file. See [Configuration](./configuration.md).
|
||||
|
||||
## Pre-built Binaries
|
||||
|
||||
Download binaries for Windows, macOS, and Linux from [GitHub Releases](https://github.com/perstarkse/minne/releases/latest).
|
||||
|
||||
**Requirements:**
|
||||
- SurrealDB instance (local or remote)
|
||||
- Chromium (for web scraping)
|
||||
|
||||
## Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/perstarkse/minne.git
|
||||
cd minne
|
||||
cargo build --release --bin main
|
||||
```
|
||||
|
||||
The binary will be at `target/release/main`.
|
||||
|
||||
**Requirements:**
|
||||
- Rust toolchain
|
||||
- SurrealDB accessible at configured address
|
||||
- Chromium in PATH
|
||||
|
||||
## Process Modes
|
||||
|
||||
Minne offers flexible deployment:
|
||||
|
||||
| Binary | Description |
|
||||
|--------|-------------|
|
||||
| `main` | Combined server + worker (recommended) |
|
||||
| `server` | Web interface and API only |
|
||||
| `worker` | Background processing only |
|
||||
|
||||
For most users, `main` is the right choice. Split deployments are useful for resource optimization or scaling.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](./configuration.md) — Environment variables and config.yaml
|
||||
- [Features](./features.md) — What Minne can do
|
||||
48
docs/vision.md
Normal file
48
docs/vision.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Vision
|
||||
|
||||
## The "Why" Behind Minne
|
||||
|
||||
Personal knowledge management has always fascinated me. I wanted something that made it incredibly easy to capture content—snippets of text, URLs, media—while automatically discovering connections between ideas. But I also wanted control over my knowledge structure.
|
||||
|
||||
Traditional tools like Logseq and Obsidian are excellent, but manual linking often becomes a hindrance. Fully automated systems sometimes miss important context or create relationships I wouldn't have chosen.
|
||||
|
||||
Minne offers the best of both worlds: effortless capture with AI-assisted relationship discovery, but with flexibility to manually curate, edit, or override connections. Let AI handle the heavy lifting, take full control yourself, or use a hybrid approach where AI suggests and you approve.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Capture should be instant** — No friction between thought and storage
|
||||
- **Connections should emerge** — AI finds relationships you might miss
|
||||
- **Control should be optional** — Automate by default, curate when it matters
|
||||
- **Privacy should be default** — Self-hosted, your data stays yours
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Near-term
|
||||
|
||||
- [ ] TUI frontend with system editor integration
|
||||
- [ ] Enhanced retrieval recall via improved reranking
|
||||
- [ ] Additional content type support (e-books, research papers)
|
||||
|
||||
### Medium-term
|
||||
|
||||
- [ ] Embedded SurrealDB option (zero-config `nix run` with just `OPENAI_API_KEY`)
|
||||
- [ ] Browser extension for seamless capture
|
||||
- [ ] Mobile-native apps
|
||||
|
||||
### Long-term
|
||||
|
||||
- [ ] Federated knowledge sharing (opt-in)
|
||||
- [ ] Local LLM integration (fully offline operation)
|
||||
- [ ] Plugin system for custom entity extractors
|
||||
|
||||
## Related Projects
|
||||
|
||||
If Minne isn't quite right for you, check out:
|
||||
|
||||
- [Karakeep](https://github.com/karakeep-app/karakeep) (formerly Hoarder) — Excellent bookmark/read-later with AI tagging
|
||||
- [Logseq](https://logseq.com/) — Outliner-based PKM with manual linking
|
||||
- [Obsidian](https://obsidian.md/) — Markdown-based PKM with plugin ecosystem
|
||||
|
||||
## Contributing
|
||||
|
||||
Feature requests and contributions are welcome. Minne was built for personal use first, but the self-hosted community benefits when we share.
|
||||
212
evaluations/README.md
Normal file
212
evaluations/README.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Evaluations
|
||||
|
||||
The `evaluations` crate provides a retrieval evaluation framework for benchmarking Minne's information retrieval pipeline against standard datasets.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run SQuAD v2.0 evaluation (vector-only, recommended)
|
||||
cargo run --package evaluations -- --ingest-chunks-only
|
||||
|
||||
# Run a specific dataset
|
||||
cargo run --package evaluations -- --dataset fiqa --ingest-chunks-only
|
||||
|
||||
# Convert dataset only (no evaluation)
|
||||
cargo run --package evaluations -- --convert-only
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. SurrealDB
|
||||
|
||||
Start a SurrealDB instance before running evaluations:
|
||||
|
||||
```bash
|
||||
docker-compose up -d surrealdb
|
||||
```
|
||||
|
||||
Or using the default endpoint configuration:
|
||||
|
||||
```bash
|
||||
surreal start --user root_user --pass root_password
|
||||
```
|
||||
|
||||
### 2. Download Raw Datasets
|
||||
|
||||
Raw datasets must be downloaded manually and placed in `evaluations/data/raw/`. See [Dataset Sources](#dataset-sources) below for links and formats.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
evaluations/
|
||||
├── data/
|
||||
│ ├── raw/ # Downloaded raw datasets (manual)
|
||||
│ │ ├── squad/ # SQuAD v2.0
|
||||
│ │ ├── nq-dev/ # Natural Questions
|
||||
│ │ ├── fiqa/ # BEIR: FiQA-2018
|
||||
│ │ ├── fever/ # BEIR: FEVER
|
||||
│ │ ├── hotpotqa/ # BEIR: HotpotQA
|
||||
│ │ └── ... # Other BEIR subsets
|
||||
│ └── converted/ # Auto-generated (Minne JSON format)
|
||||
├── cache/ # Ingestion and embedding caches
|
||||
├── reports/ # Evaluation output (JSON + Markdown)
|
||||
├── manifest.yaml # Dataset and slice definitions
|
||||
└── src/ # Evaluation source code
|
||||
```
|
||||
|
||||
## Dataset Sources
|
||||
|
||||
### SQuAD v2.0
|
||||
|
||||
Download and place at `data/raw/squad/dev-v2.0.json`:
|
||||
|
||||
```bash
|
||||
mkdir -p evaluations/data/raw/squad
|
||||
curl -L https://rajpurkar.github.io/SQuAD-explorer/dataset/dev-v2.0.json \
|
||||
-o evaluations/data/raw/squad/dev-v2.0.json
|
||||
```
|
||||
|
||||
### Natural Questions (NQ)
|
||||
|
||||
Download and place at `data/raw/nq-dev/dev-all.jsonl`:
|
||||
|
||||
```bash
|
||||
mkdir -p evaluations/data/raw/nq-dev
|
||||
# Download from Google's Natural Questions page or HuggingFace
|
||||
# File: dev-all.jsonl (simplified JSONL format)
|
||||
```
|
||||
|
||||
Source: [Google Natural Questions](https://ai.google.com/research/NaturalQuestions)
|
||||
|
||||
### BEIR Datasets
|
||||
|
||||
All BEIR datasets follow the same format structure:
|
||||
|
||||
```
|
||||
data/raw/<dataset>/
|
||||
├── corpus.jsonl # Document corpus
|
||||
├── queries.jsonl # Query set
|
||||
└── qrels/
|
||||
└── test.tsv # Relevance judgments (or dev.tsv)
|
||||
```
|
||||
|
||||
Download datasets from the [BEIR Benchmark repository](https://github.com/beir-cellar/beir). Each dataset zip extracts to the required directory structure.
|
||||
|
||||
| Dataset | Directory |
|
||||
|------------|---------------|
|
||||
| FEVER | `fever/` |
|
||||
| FiQA-2018 | `fiqa/` |
|
||||
| HotpotQA | `hotpotqa/` |
|
||||
| NFCorpus | `nfcorpus/` |
|
||||
| Quora | `quora/` |
|
||||
| TREC-COVID | `trec-covid/` |
|
||||
| SciFact | `scifact/` |
|
||||
| NQ (BEIR) | `nq/` |
|
||||
|
||||
Example download:
|
||||
|
||||
```bash
|
||||
cd evaluations/data/raw
|
||||
curl -L https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/fiqa.zip -o fiqa.zip
|
||||
unzip fiqa.zip && rm fiqa.zip
|
||||
```
|
||||
|
||||
## Dataset Conversion
|
||||
|
||||
Raw datasets are automatically converted to Minne's internal JSON format on first run. To force reconversion:
|
||||
|
||||
```bash
|
||||
cargo run --package evaluations -- --force-convert
|
||||
```
|
||||
|
||||
Converted files are saved to `data/converted/` and cached for subsequent runs.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Common Options
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--dataset <NAME>` | Dataset to evaluate | `squad-v2` |
|
||||
| `--limit <N>` | Max questions to evaluate (0 = all) | `200` |
|
||||
| `--k <N>` | Precision@k cutoff | `5` |
|
||||
| `--slice <ID>` | Use a predefined slice from manifest | — |
|
||||
| `--rerank` | Enable FastEmbed reranking stage | disabled |
|
||||
| `--embedding-backend <BE>` | `fastembed` or `hashed` | `fastembed` |
|
||||
| `--ingest-chunks-only` | Skip entity extraction, ingest only text chunks | disabled |
|
||||
|
||||
> [!TIP]
|
||||
> Use `--ingest-chunks-only` when evaluating vector-only retrieval strategies. This skips the LLM-based entity extraction and graph generation, significantly speeding up ingestion while focusing on pure chunk-based vector search.
|
||||
|
||||
### Available Datasets
|
||||
|
||||
```
|
||||
squad-v2, natural-questions, beir, fever, fiqa, hotpotqa,
|
||||
nfcorpus, quora, trec-covid, scifact, nq-beir
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
|
||||
| Flag | Environment | Default |
|
||||
|------|-------------|---------|
|
||||
| `--db-endpoint` | `EVAL_DB_ENDPOINT` | `ws://127.0.0.1:8000` |
|
||||
| `--db-username` | `EVAL_DB_USERNAME` | `root_user` |
|
||||
| `--db-password` | `EVAL_DB_PASSWORD` | `root_password` |
|
||||
| `--db-namespace` | `EVAL_DB_NAMESPACE` | auto-generated |
|
||||
| `--db-database` | `EVAL_DB_DATABASE` | auto-generated |
|
||||
|
||||
### Example Runs
|
||||
|
||||
```bash
|
||||
# Vector-only evaluation (recommended for benchmarking)
|
||||
cargo run --package evaluations -- \
|
||||
--dataset fiqa \
|
||||
--ingest-chunks-only \
|
||||
--limit 200
|
||||
|
||||
# Full FiQA evaluation with reranking
|
||||
cargo run --package evaluations -- \
|
||||
--dataset fiqa \
|
||||
--ingest-chunks-only \
|
||||
--limit 500 \
|
||||
--rerank \
|
||||
--k 10
|
||||
|
||||
# Use a predefined slice for reproducibility
|
||||
cargo run --package evaluations -- --slice fiqa-test-200 --ingest-chunks-only
|
||||
|
||||
# Run the mixed BEIR benchmark
|
||||
cargo run --package evaluations -- --dataset beir --slice beir-mix-600 --ingest-chunks-only
|
||||
```
|
||||
|
||||
## Slices
|
||||
|
||||
Slices are predefined, reproducible subsets defined in `manifest.yaml`. Each slice specifies:
|
||||
|
||||
- **limit**: Number of questions
|
||||
- **corpus_limit**: Maximum corpus size
|
||||
- **seed**: Fixed RNG seed for reproducibility
|
||||
|
||||
View available slices in [manifest.yaml](./manifest.yaml).
|
||||
|
||||
## Reports
|
||||
|
||||
Evaluations generate reports in `reports/`:
|
||||
|
||||
- **JSON**: Full structured results (`*-report.json`)
|
||||
- **Markdown**: Human-readable summary with sample mismatches (`*-report.md`)
|
||||
- **History**: Timestamped run history (`history/`)
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
```bash
|
||||
# Log per-stage performance timings
|
||||
cargo run --package evaluations -- --perf-log-console
|
||||
|
||||
# Save telemetry to file
|
||||
cargo run --package evaluations -- --perf-log-json ./perf.json
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
See [../LICENSE](../LICENSE).
|
||||
@@ -893,158 +893,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_manifest_with_transactional_batches() {
|
||||
let namespace = "test_ns";
|
||||
let database = Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, &database)
|
||||
.await
|
||||
.expect("memory db");
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("apply migrations for memory db");
|
||||
|
||||
let manifest = build_manifest();
|
||||
seed_manifest_into_db(&db, &manifest)
|
||||
.await
|
||||
.expect("manifest seed should succeed");
|
||||
|
||||
let text_contents: Vec<TextContent> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", TextContent::table_name()))
|
||||
.await
|
||||
.expect("select text_content")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(text_contents.len(), 1);
|
||||
|
||||
let entities: Vec<KnowledgeEntity> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", KnowledgeEntity::table_name()))
|
||||
.await
|
||||
.expect("select knowledge_entity")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(entities.len(), 1);
|
||||
|
||||
let chunks: Vec<TextChunk> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", TextChunk::table_name()))
|
||||
.await
|
||||
.expect("select text_chunk")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(chunks.len(), 1);
|
||||
|
||||
let relationships: Vec<KnowledgeRelationship> = db
|
||||
.client
|
||||
.query("SELECT * FROM relates_to;")
|
||||
.await
|
||||
.expect("select relates_to")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(relationships.len(), 1);
|
||||
|
||||
let entity_embeddings: Vec<KnowledgeEntityEmbedding> = db
|
||||
.client
|
||||
.query(format!(
|
||||
"SELECT * FROM {};",
|
||||
KnowledgeEntityEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.expect("select knowledge_entity_embedding")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(entity_embeddings.len(), 1);
|
||||
|
||||
let chunk_embeddings: Vec<TextChunkEmbedding> = db
|
||||
.client
|
||||
.query(format!(
|
||||
"SELECT * FROM {};",
|
||||
TextChunkEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.expect("select text_chunk_embedding")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(chunk_embeddings.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rolls_back_when_embeddings_mismatch_index_dimension() {
|
||||
let namespace = "test_ns_rollback";
|
||||
let database = Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, &database)
|
||||
.await
|
||||
.expect("memory db");
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("apply migrations for memory db");
|
||||
|
||||
let manifest = build_manifest();
|
||||
let result = seed_manifest_into_db(&db, &manifest).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"seeding should succeed even if embedding dimensions differ from default index"
|
||||
);
|
||||
|
||||
let text_contents: Vec<TextContent> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", TextContent::table_name()))
|
||||
.await
|
||||
.expect("select text_content")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
let entities: Vec<KnowledgeEntity> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", KnowledgeEntity::table_name()))
|
||||
.await
|
||||
.expect("select knowledge_entity")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
let chunks: Vec<TextChunk> = db
|
||||
.client
|
||||
.query(format!("SELECT * FROM {};", TextChunk::table_name()))
|
||||
.await
|
||||
.expect("select text_chunk")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
let relationships: Vec<KnowledgeRelationship> = db
|
||||
.client
|
||||
.query("SELECT * FROM relates_to;")
|
||||
.await
|
||||
.expect("select relates_to")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
let entity_embeddings: Vec<KnowledgeEntityEmbedding> = db
|
||||
.client
|
||||
.query(format!(
|
||||
"SELECT * FROM {};",
|
||||
KnowledgeEntityEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.expect("select knowledge_entity_embedding")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
let chunk_embeddings: Vec<TextChunkEmbedding> = db
|
||||
.client
|
||||
.query(format!(
|
||||
"SELECT * FROM {};",
|
||||
TextChunkEmbedding::table_name()
|
||||
))
|
||||
.await
|
||||
.expect("select text_chunk_embedding")
|
||||
.take(0)
|
||||
.unwrap_or_default();
|
||||
|
||||
assert_eq!(text_contents.len(), 1);
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(relationships.len(), 1);
|
||||
assert_eq!(entity_embeddings.len(), 1);
|
||||
assert_eq!(chunk_embeddings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_manifest_trims_questions_and_negatives() {
|
||||
let manifest = build_manifest();
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use common::storage::{db::SurrealDbClient, types::text_chunk::TextChunk};
|
||||
|
||||
use crate::{args::Config, eval::connect_eval_db, corpus, snapshot::DbSnapshotState};
|
||||
use crate::{args::Config, corpus, eval::connect_eval_db, snapshot::DbSnapshotState};
|
||||
|
||||
pub async fn inspect_question(config: &Config) -> Result<()> {
|
||||
let question_id = config
|
||||
|
||||
@@ -20,9 +20,10 @@ use retrieval_pipeline::{
|
||||
use crate::{
|
||||
args::Config,
|
||||
cache::EmbeddingCache,
|
||||
corpus,
|
||||
datasets::ConvertedDataset,
|
||||
eval::{CaseDiagnostics, CaseSummary, EvaluationStageTimings, EvaluationSummary, SeededCase},
|
||||
corpus, slice, snapshot,
|
||||
slice, snapshot,
|
||||
};
|
||||
|
||||
pub(super) struct EvaluationContext<'a> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::Instant;
|
||||
use anyhow::Context;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{eval::can_reuse_namespace, corpus, slice, snapshot};
|
||||
use crate::{corpus, eval::can_reuse_namespace, slice, snapshot};
|
||||
|
||||
use super::super::{
|
||||
context::{EvalStage, EvaluationContext},
|
||||
|
||||
@@ -5,12 +5,12 @@ use common::storage::types::system_settings::SystemSettings;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
corpus,
|
||||
db_helpers::{recreate_indexes, remove_all_indexes, reset_namespace},
|
||||
eval::{
|
||||
can_reuse_namespace, cases_from_manifest, enforce_system_settings, ensure_eval_user,
|
||||
record_namespace_state, warm_hnsw_cache,
|
||||
},
|
||||
corpus,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
|
||||
@@ -48,7 +48,9 @@ pub(crate) async fn prepare_slice(
|
||||
.database
|
||||
.db_namespace
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_namespace(ctx.dataset().metadata.id.as_str(), ctx.config().limit));
|
||||
.unwrap_or_else(|| {
|
||||
default_namespace(ctx.dataset().metadata.id.as_str(), ctx.config().limit)
|
||||
});
|
||||
ctx.database = ctx
|
||||
.config()
|
||||
.database
|
||||
|
||||
@@ -608,7 +608,7 @@
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.markdown-content :not(pre) > code {
|
||||
.markdown-content :not(pre)>code {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-base-content);
|
||||
padding: 0.15em 0.4em;
|
||||
@@ -662,7 +662,7 @@
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .markdown-content :not(pre) > code {
|
||||
[data-theme="dark"] .markdown-content :not(pre)>code {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
@@ -677,6 +677,136 @@
|
||||
z-index: 9999;
|
||||
box-shadow: var(--nb-shadow);
|
||||
}
|
||||
|
||||
/* .nb-label: Uppercase, bold, tracking-wide, text-xs for section headers */
|
||||
.nb-label {
|
||||
@apply uppercase font-bold tracking-wide text-xs;
|
||||
}
|
||||
|
||||
/* .nb-data: JetBrains Mono, tabular-nums for timestamps, IDs, badges */
|
||||
.nb-data {
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The Stamp: Button :active state pushes into page */
|
||||
.nb-btn:active {
|
||||
transform: translate(2px, 2px) !important;
|
||||
box-shadow: 2px 2px 0 0 #000 !important;
|
||||
}
|
||||
|
||||
/* Staggered Card Dealing Animation */
|
||||
@keyframes deal-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered deal-in animation - STRICTLY SCOPED to main content area */
|
||||
main .nb-card,
|
||||
main .nb-panel {
|
||||
animation: deal-in 300ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) backwards;
|
||||
}
|
||||
|
||||
/* Exclude elements that shouldn't animate even inside main */
|
||||
main nav.nb-panel,
|
||||
main .no-animation {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Apply staggered delays only to direct children of grids/lists or top-level containers */
|
||||
main .nb-masonry>.nb-card:nth-child(1),
|
||||
main .grid>.nb-panel:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(2),
|
||||
main .grid>.nb-panel:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(3),
|
||||
main .grid>.nb-panel:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(4),
|
||||
main .grid>.nb-panel:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(5),
|
||||
main .grid>.nb-panel:nth-child(5) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(6),
|
||||
main .grid>.nb-panel:nth-child(6) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(7),
|
||||
main .grid>.nb-panel:nth-child(7) {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(8),
|
||||
main .grid>.nb-panel:nth-child(8) {
|
||||
animation-delay: 350ms;
|
||||
}
|
||||
|
||||
main .nb-masonry>.nb-card:nth-child(n+9),
|
||||
main .grid>.nb-panel:nth-child(n+9) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
/* HTMX Swap Fade-Up Animation */
|
||||
@keyframes fade-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fade-up 200ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) forwards;
|
||||
}
|
||||
|
||||
/* Kinetic Input: Chat Armed State */
|
||||
#chat-input:not(:placeholder-shown)~button {
|
||||
filter: saturate(1.3) brightness(1.1);
|
||||
}
|
||||
|
||||
#chat-input:not(:placeholder-shown) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Evidence Frame for images (Tufte treatment) */
|
||||
.nb-evidence-frame {
|
||||
@apply border-2 border-neutral m-2 bg-base-200;
|
||||
}
|
||||
|
||||
.nb-evidence-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nb-evidence-frame figcaption {
|
||||
@apply text-xs px-2 py-1 border-t-2 border-neutral;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme-aware placeholder contrast tweaks */
|
||||
@@ -691,6 +821,31 @@
|
||||
color: rgba(255, 255, 255, 0.78) !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* === DESIGN POLISHING: Receding Reality === */
|
||||
/* Modal opens → background scales and blurs */
|
||||
body:has(dialog[open]) #main-content-wrapper,
|
||||
body.modal-open #main-content-wrapper {
|
||||
transform: scale(0.98);
|
||||
filter: blur(2px);
|
||||
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||
}
|
||||
|
||||
#main-content-wrapper {
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||
}
|
||||
|
||||
/* === DESIGN POLISHING: Scroll-Linked Navbar Shadow === */
|
||||
nav {
|
||||
--scroll-depth: 0;
|
||||
box-shadow: 4px calc(4px + var(--scroll-depth) * 4px) 0 0 #000;
|
||||
transition: box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* satoshi.css */
|
||||
@@ -714,6 +869,15 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('fonts/JetBrainsMono-Regular.woff2') format('woff2'),
|
||||
url('fonts/JetBrainsMono-Variable.ttf') format('truetype');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
|
||||
@layer utilities {
|
||||
|
||||
@@ -736,4 +900,4 @@
|
||||
.toast-alert-title {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
199
html-router/assets/design-polish.js
Normal file
199
html-router/assets/design-polish.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Design Polishing Pass - Interactive Effects
|
||||
*
|
||||
* Includes:
|
||||
* - Scroll-Linked Navbar Shadow
|
||||
* - HTMX Swap Animation
|
||||
* - Typewriter AI Response
|
||||
* - Rubberbanding Scroll
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// === SCROLL-LINKED NAVBAR SHADOW ===
|
||||
function initScrollShadow() {
|
||||
const mainContent = document.querySelector('main');
|
||||
const navbar = document.querySelector('nav');
|
||||
if (!mainContent || !navbar) return;
|
||||
|
||||
mainContent.addEventListener('scroll', () => {
|
||||
const scrollTop = mainContent.scrollTop;
|
||||
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
|
||||
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
||||
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// === HTMX SWAP ANIMATION ===
|
||||
function initHtmxSwapAnimation() {
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
let target = event.detail.target;
|
||||
if (!target) return;
|
||||
|
||||
// If full body swap (hx-boost), animate only the main content
|
||||
if (target.tagName === 'BODY') {
|
||||
const main = document.querySelector('main');
|
||||
if (main) target = main;
|
||||
}
|
||||
|
||||
// Only animate if target is valid and inside/is main content or a card/panel
|
||||
// Avoid animating sidebar or navbar updates
|
||||
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
|
||||
if (!target.classList.contains('animate-fade-up')) {
|
||||
target.classList.add('animate-fade-up');
|
||||
// Remove class after animation completes to allow re-animation
|
||||
setTimeout(() => {
|
||||
target.classList.remove('animate-fade-up');
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === TYPEWRITER AI RESPONSE ===
|
||||
// Works with SSE streaming - buffers text and reveals character by character
|
||||
window.initTypewriter = function(element, options = {}) {
|
||||
const {
|
||||
minDelay = 5,
|
||||
maxDelay = 15,
|
||||
showCursor = true
|
||||
} = options;
|
||||
|
||||
let buffer = '';
|
||||
let isTyping = false;
|
||||
let cursorElement = null;
|
||||
|
||||
if (showCursor) {
|
||||
cursorElement = document.createElement('span');
|
||||
cursorElement.className = 'typewriter-cursor';
|
||||
cursorElement.textContent = '▌';
|
||||
cursorElement.style.animation = 'blink 1s step-end infinite';
|
||||
element.appendChild(cursorElement);
|
||||
}
|
||||
|
||||
function typeNextChar() {
|
||||
if (buffer.length === 0) {
|
||||
isTyping = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isTyping = true;
|
||||
const char = buffer.charAt(0);
|
||||
buffer = buffer.slice(1);
|
||||
|
||||
// Insert before cursor
|
||||
if (cursorElement && cursorElement.parentNode) {
|
||||
const textNode = document.createTextNode(char);
|
||||
element.insertBefore(textNode, cursorElement);
|
||||
} else {
|
||||
element.textContent += char;
|
||||
}
|
||||
|
||||
const delay = minDelay + Math.random() * (maxDelay - minDelay);
|
||||
setTimeout(typeNextChar, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
append: function(text) {
|
||||
buffer += text;
|
||||
if (!isTyping) {
|
||||
typeNextChar();
|
||||
}
|
||||
},
|
||||
complete: function() {
|
||||
// Flush remaining buffer immediately
|
||||
if (cursorElement && cursorElement.parentNode) {
|
||||
const textNode = document.createTextNode(buffer);
|
||||
element.insertBefore(textNode, cursorElement);
|
||||
cursorElement.remove();
|
||||
} else {
|
||||
element.textContent += buffer;
|
||||
}
|
||||
buffer = '';
|
||||
isTyping = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// === RUBBERBANDING SCROLL ===
|
||||
function initRubberbanding() {
|
||||
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
|
||||
|
||||
containers.forEach(container => {
|
||||
let startY = 0;
|
||||
let pulling = false;
|
||||
let pullDistance = 0;
|
||||
const maxPull = 60;
|
||||
const resistance = 0.4;
|
||||
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (e) => {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diff = currentY - startY;
|
||||
|
||||
// At top boundary, pulling down
|
||||
if (container.scrollTop <= 0 && diff > 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.min(diff * resistance, maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
// At bottom boundary, pulling up
|
||||
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.max(diff * resistance, -maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
if (pulling) {
|
||||
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
|
||||
container.style.transform = 'translateY(0)';
|
||||
setTimeout(() => {
|
||||
container.style.transition = '';
|
||||
}, 300);
|
||||
pulling = false;
|
||||
pullDistance = 0;
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// === INITIALIZATION ===
|
||||
function init() {
|
||||
initScrollShadow();
|
||||
initHtmxSwapAnimation();
|
||||
initRubberbanding();
|
||||
}
|
||||
|
||||
// Run on DOMContentLoaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Re-init rubberbanding after HTMX navigations
|
||||
document.body.addEventListener('htmx:afterSettle', () => {
|
||||
initRubberbanding();
|
||||
});
|
||||
|
||||
// Add typewriter cursor blink animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.typewriter-cursor {
|
||||
color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
})();
|
||||
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
@@ -1,144 +0,0 @@
|
||||
//==========================================================
|
||||
// head-support.js
|
||||
//
|
||||
// An extension to add head tag merging.
|
||||
//==========================================================
|
||||
(function(){
|
||||
|
||||
var api = null;
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function mergeHead(newContent, defaultMergeStrategy) {
|
||||
|
||||
if (newContent && newContent.indexOf('<head') > -1) {
|
||||
const htmlDoc = document.createElement("html");
|
||||
// remove svgs to avoid conflicts
|
||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
// extract head tag
|
||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
||||
|
||||
// if the head tag exists...
|
||||
if (headTag) {
|
||||
|
||||
var added = []
|
||||
var removed = []
|
||||
var preserved = []
|
||||
var nodesToAppend = []
|
||||
|
||||
htmlDoc.innerHTML = headTag;
|
||||
var newHeadTag = htmlDoc.querySelector("head");
|
||||
var currentHead = document.head;
|
||||
|
||||
if (newHeadTag == null) {
|
||||
return;
|
||||
} else {
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
var srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// determine merge strategy
|
||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
||||
|
||||
// get the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (mergeStrategy === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the tremaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
||||
log(newElt);
|
||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
||||
currentHead.appendChild(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("head-support", {
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
htmx.on('htmx:afterSwap', function(evt){
|
||||
let xhr = evt.detail.xhr;
|
||||
if (xhr) {
|
||||
var serverResponse = xhr.response;
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyRestore', function(evt){
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
if (evt.detail.cacheMiss) {
|
||||
mergeHead(evt.detail.serverResponse, "merge");
|
||||
} else {
|
||||
mergeHead(evt.detail.item.head, "merge");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyItemCreated', function(evt){
|
||||
var historyItem = evt.detail.item;
|
||||
historyItem.head = document.head.outerHTML;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
File diff suppressed because one or more lines are too long
@@ -41,6 +41,7 @@ pub struct ScratchpadDetailData {
|
||||
user: User,
|
||||
scratchpad: ScratchpadDetail,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
is_editing_title: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -180,8 +181,8 @@ pub async fn show_scratchpad_modal(
|
||||
|
||||
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
|
||||
|
||||
// Handle edit_title query parameter if needed in future
|
||||
let _ = query.edit_title.unwrap_or(false);
|
||||
// Handle edit_title query parameter
|
||||
let is_editing_title = query.edit_title.unwrap_or(false);
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"scratchpad/editor_modal.html",
|
||||
@@ -189,6 +190,7 @@ pub async fn show_scratchpad_modal(
|
||||
user,
|
||||
scratchpad: scratchpad_detail,
|
||||
conversation_archive,
|
||||
is_editing_title,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -263,6 +265,7 @@ pub async fn update_scratchpad_title(
|
||||
user,
|
||||
scratchpad: ScratchpadDetail::from(&scratchpad),
|
||||
conversation_archive,
|
||||
is_editing_title: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, str::FromStr,
|
||||
fmt,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -198,59 +199,60 @@ pub async fn search_result_handler(
|
||||
}
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let (search_results_for_template, final_query_param_for_template) =
|
||||
if let Some(actual_query) = params.query {
|
||||
let trimmed_query = actual_query.trim();
|
||||
if trimmed_query.is_empty() {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
let (search_results_for_template, final_query_param_for_template) = if let Some(actual_query) =
|
||||
params.query
|
||||
{
|
||||
let trimmed_query = actual_query.trim();
|
||||
if trimmed_query.is_empty() {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
} else {
|
||||
// Use retrieval pipeline Search strategy
|
||||
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
||||
|
||||
// Checkout a reranker lease if pool is available
|
||||
let reranker_lease = match &state.reranker_pool {
|
||||
Some(pool) => Some(pool.checkout().await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = retrieval_pipeline::pipeline::run_pipeline(
|
||||
&state.db,
|
||||
&state.openai_client,
|
||||
Some(&state.embedding_provider),
|
||||
trimmed_query,
|
||||
&user.id,
|
||||
config,
|
||||
reranker_lease,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let search_result = match result {
|
||||
StrategyOutput::Search(sr) => sr,
|
||||
_ => SearchResult::new(vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut source_ids = HashSet::new();
|
||||
for chunk_result in &search_result.chunks {
|
||||
source_ids.insert(chunk_result.chunk.source_id.clone());
|
||||
}
|
||||
for entity_result in &search_result.entities {
|
||||
source_ids.insert(entity_result.entity.source_id.clone());
|
||||
}
|
||||
|
||||
let source_label_map = if source_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
// Use retrieval pipeline Search strategy
|
||||
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
||||
|
||||
// Checkout a reranker lease if pool is available
|
||||
let reranker_lease = match &state.reranker_pool {
|
||||
Some(pool) => Some(pool.checkout().await),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = retrieval_pipeline::pipeline::run_pipeline(
|
||||
&state.db,
|
||||
&state.openai_client,
|
||||
Some(&state.embedding_provider),
|
||||
trimmed_query,
|
||||
&user.id,
|
||||
config,
|
||||
reranker_lease,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let search_result = match result {
|
||||
StrategyOutput::Search(sr) => sr,
|
||||
_ => SearchResult::new(vec![], vec![]),
|
||||
};
|
||||
|
||||
let mut source_ids = HashSet::new();
|
||||
for chunk_result in &search_result.chunks {
|
||||
source_ids.insert(chunk_result.chunk.source_id.clone());
|
||||
}
|
||||
for entity_result in &search_result.entities {
|
||||
source_ids.insert(entity_result.entity.source_id.clone());
|
||||
}
|
||||
|
||||
let source_label_map = if source_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let record_ids: Vec<RecordId> = source_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
if id.contains(':') {
|
||||
RecordId::from_str(id).ok()
|
||||
} else {
|
||||
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut response = state
|
||||
let record_ids: Vec<RecordId> = source_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
if id.contains(':') {
|
||||
RecordId::from_str(id).ok()
|
||||
} else {
|
||||
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut response = state
|
||||
.db
|
||||
.client
|
||||
.query(
|
||||
@@ -260,84 +262,84 @@ pub async fn search_result_handler(
|
||||
.bind(("user_id", user.id.clone()))
|
||||
.bind(("record_ids", record_ids))
|
||||
.await?;
|
||||
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
||||
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
||||
|
||||
tracing::debug!(
|
||||
source_id_count = source_ids.len(),
|
||||
label_row_count = contents.len(),
|
||||
"Resolved search source labels"
|
||||
tracing::debug!(
|
||||
source_id_count = source_ids.len(),
|
||||
label_row_count = contents.len(),
|
||||
"Resolved search source labels"
|
||||
);
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
for content in contents {
|
||||
let label = build_source_label(&content);
|
||||
labels.insert(content.id.clone(), label.clone());
|
||||
labels.insert(
|
||||
format!("{}:{}", TextContent::table_name(), content.id),
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
for content in contents {
|
||||
let label = build_source_label(&content);
|
||||
labels.insert(content.id.clone(), label.clone());
|
||||
labels.insert(
|
||||
format!("{}:{}", TextContent::table_name(), content.id),
|
||||
label,
|
||||
);
|
||||
}
|
||||
labels
|
||||
};
|
||||
|
||||
labels
|
||||
};
|
||||
let mut combined_results: Vec<SearchResultForTemplate> =
|
||||
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
||||
|
||||
let mut combined_results: Vec<SearchResultForTemplate> =
|
||||
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
||||
|
||||
// Add chunk results
|
||||
for chunk_result in search_result.chunks {
|
||||
let source_label = source_label_map
|
||||
.get(&chunk_result.chunk.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "text_chunk".to_string(),
|
||||
// Add chunk results
|
||||
for chunk_result in search_result.chunks {
|
||||
let source_label = source_label_map
|
||||
.get(&chunk_result.chunk.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "text_chunk".to_string(),
|
||||
score: chunk_result.score,
|
||||
text_chunk: Some(TextChunkForTemplate {
|
||||
id: chunk_result.chunk.id,
|
||||
source_id: chunk_result.chunk.source_id,
|
||||
source_label,
|
||||
chunk: chunk_result.chunk.chunk,
|
||||
score: chunk_result.score,
|
||||
text_chunk: Some(TextChunkForTemplate {
|
||||
id: chunk_result.chunk.id,
|
||||
source_id: chunk_result.chunk.source_id,
|
||||
source_label,
|
||||
chunk: chunk_result.chunk.chunk,
|
||||
score: chunk_result.score,
|
||||
}),
|
||||
knowledge_entity: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Add entity results
|
||||
for entity_result in search_result.entities {
|
||||
let source_label = source_label_map
|
||||
.get(&entity_result.entity.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "knowledge_entity".to_string(),
|
||||
score: entity_result.score,
|
||||
text_chunk: None,
|
||||
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
||||
id: entity_result.entity.id,
|
||||
name: entity_result.entity.name,
|
||||
description: entity_result.entity.description,
|
||||
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
||||
source_id: entity_result.entity.source_id,
|
||||
source_label,
|
||||
score: entity_result.score,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
|
||||
// Limit results
|
||||
const TOTAL_LIMIT: usize = 10;
|
||||
combined_results.truncate(TOTAL_LIMIT);
|
||||
|
||||
(combined_results, trimmed_query.to_string())
|
||||
}),
|
||||
knowledge_entity: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
};
|
||||
|
||||
// Add entity results
|
||||
for entity_result in search_result.entities {
|
||||
let source_label = source_label_map
|
||||
.get(&entity_result.entity.source_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
||||
combined_results.push(SearchResultForTemplate {
|
||||
result_type: "knowledge_entity".to_string(),
|
||||
score: entity_result.score,
|
||||
text_chunk: None,
|
||||
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
||||
id: entity_result.entity.id,
|
||||
name: entity_result.entity.name,
|
||||
description: entity_result.entity.description,
|
||||
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
||||
source_id: entity_result.entity.source_id,
|
||||
source_label,
|
||||
score: entity_result.score,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
|
||||
// Limit results
|
||||
const TOTAL_LIMIT: usize = 10;
|
||||
combined_results.truncate(TOTAL_LIMIT);
|
||||
|
||||
(combined_results, trimmed_query.to_string())
|
||||
}
|
||||
} else {
|
||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||
};
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"search/base.html",
|
||||
|
||||
@@ -8,44 +8,31 @@
|
||||
<section class="nb-panel p-4 sm:p-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-extrabold tracking-tight">Admin Controls</h1>
|
||||
<p class="text-sm opacity-70 max-w-2xl">
|
||||
Stay on top of analytics and manage AI integrations without waiting on long-running model calls.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-xs opacity-60 sm:text-right">
|
||||
Signed in as <span class="font-medium">{{ user.email }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav
|
||||
class="nb-panel p-2 flex flex-wrap gap-2 text-sm"
|
||||
hx-boost="true"
|
||||
hx-target="#admin-shell"
|
||||
hx-select="#admin-shell"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<a
|
||||
href="/admin?section=overview"
|
||||
class="nb-btn btn-sm px-4 {% if current_section == 'overview' %}nb-cta{% else %}btn-ghost{% endif %}"
|
||||
>
|
||||
<nav class="nb-panel p-2 flex flex-wrap gap-2 text-sm" hx-boost="true" hx-target="#admin-shell"
|
||||
hx-select="#admin-shell" hx-swap="outerHTML" hx-push-url="true">
|
||||
<a href="/admin?section=overview"
|
||||
class="nb-btn btn-sm px-4 {% if current_section == 'overview' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||
Overview
|
||||
</a>
|
||||
<a
|
||||
href="/admin?section=models"
|
||||
class="nb-btn btn-sm px-4 {% if current_section == 'models' %}nb-cta{% else %}btn-ghost{% endif %}"
|
||||
>
|
||||
<a href="/admin?section=models"
|
||||
class="nb-btn btn-sm px-4 {% if current_section == 'models' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||
Models
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div id="admin-content" class="flex flex-col gap-4">
|
||||
{% if current_section == 'models' %}
|
||||
{% include 'admin/sections/models.html' %}
|
||||
{% include 'admin/sections/models.html' %}
|
||||
{% else %}
|
||||
{% include 'admin/sections/overview.html' %}
|
||||
{% include 'admin/sections/overview.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "modal_base.html" %}
|
||||
|
||||
{% block modal_class %}max-w-3xl{% endblock %}
|
||||
|
||||
{% block form_attributes %}
|
||||
hx-patch="/update-image-prompt"
|
||||
hx-target="#system_prompt_section"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "modal_base.html" %}
|
||||
|
||||
{% block modal_class %}max-w-3xl{% endblock %}
|
||||
|
||||
{% block form_attributes %}
|
||||
hx-patch="/update-ingestion-prompt"
|
||||
hx-target="#system_prompt_section"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "modal_base.html" %}
|
||||
|
||||
{% block modal_class %}max-w-3xl{% endblock %}
|
||||
|
||||
{% block form_attributes %}
|
||||
hx-patch="/update-query-prompt"
|
||||
hx-target="#system_prompt_section"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
{% block body %}
|
||||
|
||||
<body class="relative" hx-ext="head-support">
|
||||
<div class="drawer lg:drawer-open">
|
||||
<body class="relative">
|
||||
<div id="main-content-wrapper" class="drawer lg:drawer-open">
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<!-- Page Content -->
|
||||
<div class="drawer-content flex flex-col h-screen">
|
||||
@@ -14,6 +14,7 @@
|
||||
{% block main %}{% endblock %}
|
||||
<div class="p32 min-h-[10px]"></div>
|
||||
</main>
|
||||
{% block overlay %}{% endblock %}
|
||||
</div>
|
||||
<!-- Sidebar -->
|
||||
{% if user %}
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
{% block title %}Minne - Chat{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="/assets/htmx-ext-sse.js" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="flex grow relative justify-center mt-2 sm:mt-4">
|
||||
<div class="container">
|
||||
@@ -17,32 +13,69 @@
|
||||
</section>
|
||||
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
|
||||
{% include "chat/history.html" %}
|
||||
{% include "chat/new_message_form.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function doScrollChatToBottom() {
|
||||
const mainScroll = document.querySelector('main');
|
||||
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
|
||||
|
||||
const chatScroll = document.getElementById('chat-scroll-container');
|
||||
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
|
||||
|
||||
const chatContainer = document.getElementById('chat_container');
|
||||
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
|
||||
function scrollChatToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
const mainScroll = document.querySelector('main');
|
||||
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
|
||||
|
||||
const chatScroll = document.getElementById('chat-scroll-container');
|
||||
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
|
||||
|
||||
const chatContainer = document.getElementById('chat_container');
|
||||
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
if (!window.location.pathname.startsWith('/chat')) return;
|
||||
requestAnimationFrame(doScrollChatToBottom);
|
||||
}
|
||||
|
||||
window.scrollChatToBottom = scrollChatToBottom;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', scrollChatToBottom);
|
||||
// Delay initial scroll to avoid interfering with view transition
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(scrollChatToBottom, 350));
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
|
||||
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
|
||||
function handleChatSwap(e) {
|
||||
if (!window.location.pathname.startsWith('/chat')) return;
|
||||
// Full page swap: delay for view transition; partial swap: immediate
|
||||
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
|
||||
setTimeout(scrollChatToBottom, 350);
|
||||
} else {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupChatListeners(e) {
|
||||
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
|
||||
document.body.removeEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
|
||||
document.body.removeEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
|
||||
document.body.removeEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
|
||||
delete window._chatEventHandlers;
|
||||
window._chatListenersAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
window._chatEventHandlers = {
|
||||
afterSwap: handleChatSwap,
|
||||
afterSettle: handleChatSwap,
|
||||
beforeSwap: cleanupChatListeners
|
||||
};
|
||||
|
||||
if (!window._chatListenersAttached) {
|
||||
document.body.addEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
|
||||
document.body.addEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
|
||||
document.body.addEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
|
||||
window._chatListenersAttached = true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlay %}
|
||||
{% include "chat/new_message_form.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
|
||||
<div class="mx-auto max-w-3xl px-4 pb-3">
|
||||
<div class="nb-panel p-2">
|
||||
<div class="nb-panel p-2 no-animation">
|
||||
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
|
||||
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
|
||||
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
{% if text_contents|length > 0 %}
|
||||
<div class="nb-masonry w-full">
|
||||
{% for text_content in text_contents %}
|
||||
<article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
|
||||
<article class="nb-card cursor-pointer mx-auto mb-4 w-full space-y-3"
|
||||
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% if text_content.url_info %}
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
|
||||
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
|
||||
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
@@ -31,10 +31,10 @@
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs opacity-60 shrink-0">
|
||||
<p class="nb-data text-xs opacity-60 shrink-0">
|
||||
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||
</p>
|
||||
<span class="nb-badge">{{ text_content.category }}</span>
|
||||
<span class="nb-badge nb-data">{{ text_content.category }}</span>
|
||||
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
|
||||
{% if text_content.url_info %}
|
||||
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
{% block title %}Minne - Dashboard{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="/assets/htmx-ext-sse.js" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
|
||||
<div class="container">
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/assets/htmx.min.js" defer></script>
|
||||
<script src="/assets/htmx-ext-sse.js" defer></script>
|
||||
<script src="/assets/theme-toggle.js" defer></script>
|
||||
<script src="/assets/toast.js" defer></script>
|
||||
<script src="/assets/htmx-head-ext.js" defer></script>
|
||||
<script src="/assets/marked.min.js" defer></script>
|
||||
<script src="/assets/knowledge-graph.js" defer></script>
|
||||
<script src="/assets/design-polish.js" defer></script>
|
||||
|
||||
<!-- Global View Transition -->
|
||||
<meta name="view-transition" content="same-origin" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" href="/assets/icon/favicon.ico">
|
||||
@@ -38,6 +42,7 @@
|
||||
(function wait_for_htmx() {
|
||||
if (window.htmx) {
|
||||
htmx.config.globalViewTransitions = true;
|
||||
htmx.config.selfRequestsOnly = false;
|
||||
} else {
|
||||
setTimeout(wait_for_htmx, 50);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,78 @@
|
||||
{% extends "modal_base.html" %}
|
||||
|
||||
{% block modal_class %}max-w-3xl{% endblock %}
|
||||
|
||||
{% block form_attributes %}
|
||||
hx-post="/ingress-form"
|
||||
enctype="multipart/form-data"
|
||||
{% endblock %}
|
||||
|
||||
{% block modal_content %}
|
||||
<h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-xl font-extrabold tracking-tight pr-8">Add New Content</h3>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Content Source -->
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
|
||||
<textarea name="content" class="nb-input w-full min-h-28"
|
||||
<div class="nb-label mb-1">Content</div>
|
||||
<textarea name="content" class="nb-input w-full" rows="4" autofocus
|
||||
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
|
||||
</label>
|
||||
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
|
||||
<textarea name="context" class="nb-input w-full min-h-24"
|
||||
placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
|
||||
<!-- Context (Optional) -->
|
||||
<label class="w-full mt-6">
|
||||
<div class="nb-label mb-1 flex justify-between items-center">
|
||||
<span>Context</span>
|
||||
<!-- Tufte-style annotation: clean, small caps, structural -->
|
||||
<span class="text-[10px] tracking-widest uppercase border border-neutral px-1.5 py-px bg-transparent opacity-60">Optional</span>
|
||||
</div>
|
||||
<textarea name="context" class="nb-input w-full" rows="2"
|
||||
placeholder="Guide how this content should be interpreted…">{{ context }}</textarea>
|
||||
</label>
|
||||
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
|
||||
<input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
|
||||
<datalist id="category-list">
|
||||
{% for category in user_categories %}
|
||||
<option value="{{ category }}" />
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<div class="validator-hint hidden text-xs opacity-70 mt-1">Category is required</div>
|
||||
</label>
|
||||
<!-- Metadata Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-12 gap-y-8 items-start mt-6">
|
||||
|
||||
<!-- Category -->
|
||||
<label class="w-full">
|
||||
<div class="nb-label mb-1">Category <span class="text-error font-bold" title="Required">*</span></div>
|
||||
<div class="relative">
|
||||
<input type="text" name="category" class="nb-input validator w-full pr-8" value="{{ category }}" list="category-list" required placeholder="Select or type..." />
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-50">
|
||||
{% include "icons/chevron_icon.html" %}
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="category-list">
|
||||
{% for category in user_categories %}
|
||||
<option value="{{ category }}" />
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<div class="validator-hint hidden text-xs opacity-70 mt-1 text-error">Category is required</div>
|
||||
</label>
|
||||
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Files</div>
|
||||
<input type="file" name="files" multiple class="file-input w-full rounded-none border-2 border-neutral" />
|
||||
</label>
|
||||
<!-- Dimensional File Drop Zone -->
|
||||
<div class="w-full">
|
||||
<div class="nb-label mb-1">Files</div>
|
||||
<!-- "Card" style dropzone: solid border, hard shadow, lift on hover -->
|
||||
<div class="relative w-full h-32 group bg-base-100 border-2 border-neutral shadow-[4px_4px_0_0_#000] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[6px_6px_0_0_#000] transition-all duration-150">
|
||||
<!-- Visual Facade -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm font-medium text-neutral pointer-events-none">
|
||||
<div class="p-2 border-2 border-neutral rounded-none bg-base-200 group-hover:bg-base-100 transition-colors">
|
||||
<span class="w-6 h-6 block">{% include "icons/document_icon.html" %}</span>
|
||||
</div>
|
||||
<span id="file-label-text" class="text-center px-4 text-xs uppercase tracking-wide">Drop files or click</span>
|
||||
</div>
|
||||
<!-- Actual Input -->
|
||||
<input type="file" name="files" multiple
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onchange="const count = this.files.length; document.getElementById('file-label-text').innerText = count > 0 ? count + ' FILE' + (count !== 1 ? 'S' : '') + ' SELECTED' : 'DROP FILES OR CLICK';" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById('modal_form');
|
||||
@@ -51,8 +88,9 @@ enctype="multipart/form-data"
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block primary_actions %}
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
|
||||
Add Content
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
<dialog id="body_modal" class="modal">
|
||||
<div
|
||||
class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
|
||||
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 {% block modal_class %}max-w-lg{% endblock %}">
|
||||
|
||||
<!-- God Level UX: Explicit Escape Hatch -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
|
||||
onclick="document.getElementById('body_modal').close()"
|
||||
aria-label="Close modal">
|
||||
{% include "icons/x_icon.html" %}
|
||||
</button>
|
||||
|
||||
<form id="modal_form" {% block form_attributes %}{% endblock %}>
|
||||
<div class="flex flex-col flex-1 gap-4">
|
||||
<div class="flex flex-col flex-1 gap-5">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="u-hairline mt-4 pt-3 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
|
||||
<!-- Close button (always visible) -->
|
||||
<button type="button" class="nb-btn w-full sm:w-auto" onclick="document.getElementById('body_modal').close()">
|
||||
Close
|
||||
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
|
||||
<!-- Secondary Action: Ghost style to reduce noise -->
|
||||
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" onclick="document.getElementById('body_modal').close()">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<!-- Primary actions block -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0">
|
||||
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0" style="view-transition-name: navbar; contain: layout;">
|
||||
<div class="container mx-auto navbar">
|
||||
<div class="mr-2 flex-1">
|
||||
{% include "searchbar.html" %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block modal_content %}
|
||||
<h3 class="text-xl font-extrabold tracking-tight">
|
||||
<div class="flex items-center gap-2" id="title-container">
|
||||
<div class="flex items-center gap-2 {% if is_editing_title %}hidden{% endif %}" id="title-container">
|
||||
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
|
||||
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
|
||||
{% include "icons/edit_icon.html" %} Edit title
|
||||
@@ -15,9 +15,9 @@
|
||||
|
||||
<!-- Hidden title form -->
|
||||
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
|
||||
class="hidden flex items-center gap-2">
|
||||
class="{% if not is_editing_title %}hidden{% endif %} flex items-center gap-2">
|
||||
<input type="text" name="title" value="{{ scratchpad.title }}"
|
||||
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input">
|
||||
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input" {% if is_editing_title %}autofocus{% endif %}>
|
||||
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
|
||||
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
|
||||
</form>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
|
||||
<!-- Theme switch script -->
|
||||
<script>
|
||||
const initializeTheme = () => {
|
||||
console.log("Initializing theme toggle...");
|
||||
const themeToggle = document.querySelector('.theme-controller');
|
||||
if (!themeToggle) {
|
||||
console.log("Theme toggle not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Initialize theme from local storage or system preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
themeToggle.checked = initialTheme === 'dark';
|
||||
|
||||
// Update theme and local storage on toggle
|
||||
themeToggle.addEventListener('change', () => {
|
||||
const theme = themeToggle.checked ? 'dark' : 'light';
|
||||
console.log("Theme switched to:", theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
});
|
||||
|
||||
console.log("Theme toggle initialized.");
|
||||
};
|
||||
|
||||
// Run the initialization after the DOM is fully loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log("DOM fully loaded. Initializing theme toggle...");
|
||||
initializeTheme();
|
||||
});
|
||||
|
||||
// Reinitialize theme toggle after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', initializeTheme);
|
||||
document.addEventListener('htmx:afterSettle', initializeTheme);
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<div class="drawer-side z-20">
|
||||
<div class="drawer-side z-20" style="view-transition-name: sidebar; contain: layout;">
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- === MIDDLE SCROLLABLE SECTION === -->
|
||||
<span class="px-4 py-2 font-semibold tracking-wide">Recent Chats</span>
|
||||
<span class="px-4 py-2 nb-label">Recent Chats</span>
|
||||
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
|
||||
{% if conversation_archive is defined and conversation_archive %}
|
||||
{% for conversation in conversation_archive %}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
#![allow(
|
||||
clippy::missing_docs_in_private_items,
|
||||
clippy::result_large_err
|
||||
)]
|
||||
#![allow(clippy::missing_docs_in_private_items, clippy::result_large_err)]
|
||||
|
||||
pub mod pipeline;
|
||||
pub mod utils;
|
||||
|
||||
@@ -182,9 +182,7 @@ impl IngestionPipeline {
|
||||
.saturating_sub(1)
|
||||
.min(tuning.retry_backoff_cap_exponent);
|
||||
let multiplier = 2_u64.pow(capped_attempt);
|
||||
let delay = tuning
|
||||
.retry_base_delay_secs
|
||||
.saturating_mul(multiplier);
|
||||
let delay = tuning.retry_base_delay_secs.saturating_mul(multiplier);
|
||||
|
||||
Duration::from_secs(delay.min(tuning.retry_max_delay_secs))
|
||||
}
|
||||
|
||||
@@ -184,8 +184,9 @@ impl PipelineServices for DefaultPipelineServices {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let config =
|
||||
retrieval_pipeline::RetrievalConfig::for_search(retrieval_pipeline::SearchTarget::EntitiesOnly);
|
||||
let config = retrieval_pipeline::RetrievalConfig::for_search(
|
||||
retrieval_pipeline::SearchTarget::EntitiesOnly,
|
||||
);
|
||||
match retrieval_pipeline::retrieve_entities(
|
||||
&self.db,
|
||||
&self.openai_client,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "main"
|
||||
version = "0.2.7"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/perstarkse/minne"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -2,7 +2,9 @@ use api_router::{api_routes_v1, api_state::ApiState};
|
||||
use axum::{extract::FromRef, Router};
|
||||
use common::{
|
||||
storage::{
|
||||
db::SurrealDbClient, indexes::ensure_runtime_indexes, store::StorageManager,
|
||||
db::SurrealDbClient,
|
||||
indexes::ensure_runtime_indexes,
|
||||
store::StorageManager,
|
||||
types::{
|
||||
knowledge_entity::KnowledgeEntity, system_settings::SystemSettings,
|
||||
text_chunk::TextChunk,
|
||||
@@ -66,9 +68,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (settings, dimensions_changed) =
|
||||
SystemSettings::sync_from_embedding_provider(&db, &embedding_provider).await?;
|
||||
|
||||
// Now ensure runtime indexes with the correct (synced) dimensions
|
||||
ensure_runtime_indexes(&db, settings.embedding_dimensions as usize).await?;
|
||||
|
||||
// If dimensions changed, re-embed existing data to keep queries working.
|
||||
if dimensions_changed {
|
||||
warn!(
|
||||
@@ -78,22 +77,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Re-embed text chunks
|
||||
info!("Re-embedding TextChunks");
|
||||
if let Err(e) = TextChunk::update_all_embeddings_with_provider(
|
||||
&db,
|
||||
&embedding_provider,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
TextChunk::update_all_embeddings_with_provider(&db, &embedding_provider).await
|
||||
{
|
||||
error!("Failed to re-embed TextChunks: {}. Search results may be stale.", e);
|
||||
error!(
|
||||
"Failed to re-embed TextChunks: {}. Search results may be stale.",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Re-embed knowledge entities
|
||||
info!("Re-embedding KnowledgeEntities");
|
||||
if let Err(e) = KnowledgeEntity::update_all_embeddings_with_provider(
|
||||
&db,
|
||||
&embedding_provider,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
KnowledgeEntity::update_all_embeddings_with_provider(&db, &embedding_provider).await
|
||||
{
|
||||
error!(
|
||||
"Failed to re-embed KnowledgeEntities: {}. Search results may be stale.",
|
||||
@@ -104,6 +100,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Re-embedding complete.");
|
||||
}
|
||||
|
||||
// Now ensure runtime indexes with the correct (synced) dimensions
|
||||
ensure_runtime_indexes(&db, settings.embedding_dimensions as usize).await?;
|
||||
|
||||
let reranker_pool = RerankerPool::maybe_from_config(&config)?;
|
||||
|
||||
// Create global storage manager
|
||||
|
||||
@@ -51,9 +51,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let storage = StorageManager::new(&config).await?;
|
||||
|
||||
// Create embedding provider based on config
|
||||
let embedding_provider = Arc::new(
|
||||
EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?,
|
||||
);
|
||||
let embedding_provider =
|
||||
Arc::new(EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?);
|
||||
info!(
|
||||
embedding_backend = ?config.embedding_backend,
|
||||
embedding_dimension = embedding_provider.dimension(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::{
|
||||
storage::db::SurrealDbClient, storage::store::StorageManager,
|
||||
storage::db::SurrealDbClient,
|
||||
storage::store::StorageManager,
|
||||
utils::{config::get_config, embedding::EmbeddingProvider},
|
||||
};
|
||||
use ingestion_pipeline::{pipeline::IngestionPipeline, run_worker_loop};
|
||||
@@ -40,9 +41,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let reranker_pool = RerankerPool::maybe_from_config(&config)?;
|
||||
|
||||
// Create embedding provider based on config
|
||||
let embedding_provider = Arc::new(
|
||||
EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?,
|
||||
);
|
||||
let embedding_provider =
|
||||
Arc::new(EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?);
|
||||
info!(
|
||||
embedding_backend = ?config.embedding_backend,
|
||||
"Embedding provider initialized for worker"
|
||||
@@ -51,17 +51,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create global storage manager
|
||||
let storage = StorageManager::new(&config).await?;
|
||||
|
||||
let ingestion_pipeline = Arc::new(
|
||||
IngestionPipeline::new(
|
||||
db.clone(),
|
||||
openai_client.clone(),
|
||||
config,
|
||||
reranker_pool,
|
||||
storage,
|
||||
embedding_provider,
|
||||
)
|
||||
?,
|
||||
);
|
||||
let ingestion_pipeline = Arc::new(IngestionPipeline::new(
|
||||
db.clone(),
|
||||
openai_client.clone(),
|
||||
config,
|
||||
reranker_pool,
|
||||
storage,
|
||||
embedding_provider,
|
||||
)?);
|
||||
|
||||
run_worker_loop(db, ingestion_pipeline).await
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ mod tests {
|
||||
use common::storage::types::knowledge_relationship::KnowledgeRelationship;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_entities_by_relationship_by_id() {
|
||||
// Setup in-memory database for testing
|
||||
|
||||
@@ -301,9 +301,14 @@ mod tests {
|
||||
.await
|
||||
.expect("Search strategy retrieval failed");
|
||||
|
||||
assert!(
|
||||
matches!(results, StrategyOutput::Search(_)),
|
||||
"expected Search output, got {:?}",
|
||||
results
|
||||
);
|
||||
let search_result = match results {
|
||||
StrategyOutput::Search(sr) => sr,
|
||||
other => panic!("expected Search output, got {:?}", other),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Should return chunks (entities may be empty if none stored)
|
||||
@@ -312,7 +317,10 @@ mod tests {
|
||||
"Search strategy should return chunks"
|
||||
);
|
||||
assert!(
|
||||
search_result.chunks.iter().any(|c| c.chunk.chunk.contains("Tokio")),
|
||||
search_result
|
||||
.chunks
|
||||
.iter()
|
||||
.any(|c| c.chunk.chunk.contains("Tokio")),
|
||||
"Search results should contain relevant chunks"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
use stages::PipelineContext;
|
||||
use strategies::{DefaultStrategyDriver, IngestionDriver, RelationshipSuggestionDriver, SearchStrategyDriver};
|
||||
use strategies::{
|
||||
DefaultStrategyDriver, IngestionDriver, RelationshipSuggestionDriver, SearchStrategyDriver,
|
||||
};
|
||||
|
||||
// Export StrategyOutput publicly from this module
|
||||
// (it's defined in lib.rs but we re-export it here)
|
||||
|
||||
@@ -10,14 +10,10 @@ use common::{
|
||||
};
|
||||
use fastembed::RerankResult;
|
||||
use futures::{stream::FuturesUnordered, StreamExt};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::HashMap,
|
||||
};
|
||||
use std::{cmp::Ordering, collections::HashMap};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
|
||||
graph::find_entities_by_relationship_by_id,
|
||||
reranking::RerankerLease,
|
||||
scoring::{
|
||||
@@ -444,7 +440,6 @@ pub async fn expand_graph(ctx: &mut PipelineContext<'_>) -> Result<(), AppError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn rerank(ctx: &mut PipelineContext<'_>) -> Result<(), AppError> {
|
||||
let mut applied = false;
|
||||
@@ -572,28 +567,6 @@ pub async fn collect_vector_chunks(ctx: &mut PipelineContext<'_>) -> Result<(),
|
||||
// .filter(|c| c.scores.vector.is_some() && c.scores.fts.is_some())
|
||||
// .count();
|
||||
|
||||
// If we have very low overlap (few chunks with both signals), drop FTS-only chunks.
|
||||
// These are often noisy on keyword-heavy datasets and dilute strong vector hits.
|
||||
// Keep vector-only and “golden” (vector+FTS) chunks.
|
||||
let fts_only_count = vector_chunks
|
||||
.iter()
|
||||
.filter(|c| c.scores.vector.is_none())
|
||||
.count();
|
||||
let both_count = vector_chunks
|
||||
.iter()
|
||||
.filter(|c| c.scores.vector.is_some() && c.scores.fts.is_some())
|
||||
.count();
|
||||
if fts_only_count > 0 && both_count < 3 {
|
||||
let before_filter = vector_chunks.len();
|
||||
vector_chunks.retain(|c| c.scores.vector.is_some());
|
||||
let after_filter = vector_chunks.len();
|
||||
debug!(
|
||||
fts_only_filtered = before_filter - after_filter,
|
||||
both_signals_preserved = both_count,
|
||||
"Filtered out FTS-only chunks due to low overlap, preserved golden chunks"
|
||||
);
|
||||
}
|
||||
|
||||
debug!(
|
||||
top_fused_scores = ?vector_chunks.iter().take(5).map(|c| c.fused).collect::<Vec<_>>(),
|
||||
"Fused scores after RRF ordering"
|
||||
|
||||
@@ -8,8 +8,6 @@ use super::{
|
||||
use crate::{RetrievedChunk, RetrievedEntity};
|
||||
use common::error::AppError;
|
||||
|
||||
|
||||
|
||||
pub struct DefaultStrategyDriver;
|
||||
|
||||
impl DefaultStrategyDriver {
|
||||
@@ -89,8 +87,8 @@ impl StrategyDriver for IngestionDriver {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::SearchResult;
|
||||
use super::config::SearchTarget;
|
||||
use crate::SearchResult;
|
||||
|
||||
/// Search strategy driver that retrieves both chunks and entities
|
||||
pub struct SearchStrategyDriver {
|
||||
|
||||
Reference in New Issue
Block a user