Update dependencies and remove problematic crates

Drop image (with ravif/paste), sqlx, zitadel, and related dependencies
that were causing compilation issues. Replace image processing with
direct png crate usage. Update rcgen to 0.14 with new API changes.
Refactor CA certificate generation to use Issuer pattern.
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-03 16:05:30 -03:00
parent e8d171ea40
commit ad311944b8
15 changed files with 2256 additions and 2597 deletions

2757
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -77,7 +77,6 @@ monitoring = ["dep:sysinfo"]
automation = ["dep:rhai"] automation = ["dep:rhai"]
grpc = ["dep:tonic"] grpc = ["dep:tonic"]
progress-bars = ["dep:indicatif"] progress-bars = ["dep:indicatif"]
dynamic-db = ["dep:sqlx"]
# ===== META FEATURES (BUNDLES) ===== # ===== META FEATURES (BUNDLES) =====
full = [ full = [
@ -139,13 +138,12 @@ askama_axum = "0.4"
tracing-subscriber = { version = "0.3", features = ["fmt"] } tracing-subscriber = { version = "0.3", features = ["fmt"] }
urlencoding = "2.1" urlencoding = "2.1"
uuid = { version = "1.11", features = ["serde", "v4"] } uuid = { version = "1.11", features = ["serde", "v4"] }
zitadel = { version = "5.5.1", features = ["api", "credentials"] }
# === TLS/SECURITY DEPENDENCIES === # === TLS/SECURITY DEPENDENCIES ===
rustls = { version = "0.21", features = ["dangerous_configuration"] } rustls = { version = "0.21", features = ["dangerous_configuration"] }
rustls-pemfile = "1.0" rustls-pemfile = "1.0"
tokio-rustls = "0.24" tokio-rustls = "0.24"
rcgen = { version = "0.11", features = ["pem"] } rcgen = { version = "0.14", features = ["pem"] }
x509-parser = "0.15" x509-parser = "0.15"
rustls-native-certs = "0.6" rustls-native-certs = "0.6"
webpki-roots = "0.25" webpki-roots = "0.25"
@ -189,19 +187,16 @@ csv = { version = "1.3", optional = true }
# Console/TUI (console feature) # Console/TUI (console feature)
crossterm = { version = "0.29.0", optional = true } crossterm = { version = "0.29.0", optional = true }
ratatui = { version = "0.29.0", optional = true } ratatui = { version = "0.30.0-beta.0", optional = true }
# QR Code Generation # QR Code Generation (using png directly to avoid image's ravif/paste dependency)
image = "0.25" png = "0.18"
qrcode = "0.14" qrcode = { version = "0.14", default-features = false }
# Excel/Spreadsheet Support # Excel/Spreadsheet Support
calamine = "0.26" calamine = "0.26"
rust_xlsxwriter = "0.79" rust_xlsxwriter = "0.79"
# Database (for table_definition dynamic connections)
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "mysql"], optional = true }
# Error handling # Error handling
thiserror = "2.0" thiserror = "2.0"

View file

@ -23,6 +23,19 @@
- [.gbdrive File Storage](./chapter-02/gbdrive.md) - [.gbdrive File Storage](./chapter-02/gbdrive.md)
- [Bot Templates](./chapter-02/templates.md) - [Bot Templates](./chapter-02/templates.md)
- [Template Samples & Conversations](./chapter-02/template-samples.md) - [Template Samples & Conversations](./chapter-02/template-samples.md)
- [Template: Business Intelligence](./chapter-02/template-bi.md)
- [Template: Web Crawler](./chapter-02/template-crawler.md)
- [Template: Legal Documents](./chapter-02/template-law.md)
- [Template: LLM Server](./chapter-02/template-llm-server.md)
- [Template: LLM Tools](./chapter-02/template-llm-tools.md)
- [Template: API Client](./chapter-02/template-api-client.md)
- [Template: Platform Analytics](./chapter-02/template-analytics.md)
- [Template: Office Automation](./chapter-02/template-office.md)
- [Template: Reminders](./chapter-02/template-reminder.md)
- [Template: Sales CRM](./chapter-02/template-crm.md)
- [Template: CRM Contacts](./chapter-02/template-crm-contacts.md)
- [Template: Marketing](./chapter-02/template-marketing.md)
- [Template: Creating Templates](./chapter-02/template-template.md)
# Part III - Knowledge Base # Part III - Knowledge Base

View file

@ -24,6 +24,7 @@ Complete reference of all available parameters in `config.csv`.
| `llm-key` | API key for LLM service | `none` | String | | `llm-key` | API key for LLM service | `none` | String |
| `llm-url` | LLM service endpoint | `http://localhost:8081` | URL | | `llm-url` | LLM service endpoint | `http://localhost:8081` | URL |
| `llm-model` | Model path or identifier | Required | Path/String | | `llm-model` | Model path or identifier | Required | Path/String |
| `llm-models` | Available model aliases for routing | `default` | Semicolon-separated |
### LLM Cache ### LLM Cache
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
@ -48,6 +49,7 @@ Complete reference of all available parameters in `config.csv`.
| `llm-server-cont-batching` | Continuous batching | `true` | Boolean | | `llm-server-cont-batching` | Continuous batching | `true` | Boolean |
| `llm-server-mlock` | Lock in memory | `false` | Boolean | | `llm-server-mlock` | Lock in memory | `false` | Boolean |
| `llm-server-no-mmap` | Disable mmap | `false` | Boolean | | `llm-server-no-mmap` | Disable mmap | `false` | Boolean |
| `llm-server-reasoning-format` | Reasoning output format for llama.cpp | `none` | String |
### Hardware-Specific LLM Tuning ### Hardware-Specific LLM Tuning
@ -129,14 +131,25 @@ llm-model,mixtral-8x7b-32768
## Custom Database Parameters ## Custom Database Parameters
These parameters configure external database connections for use with BASIC keywords like MariaDB/MySQL connections.
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `custom-server` | Database server | `localhost` | Hostname | | `custom-server` | Database server hostname | `localhost` | Hostname |
| `custom-port` | Database port | `5432` | Number | | `custom-port` | Database port | `3306` | Number |
| `custom-database` | Database name | Not set | String | | `custom-database` | Database name | Not set | String |
| `custom-username` | Database user | Not set | String | | `custom-username` | Database user | Not set | String |
| `custom-password` | Database password | Not set | String | | `custom-password` | Database password | Not set | String |
### Example: MariaDB Connection
```csv
custom-server,db.example.com
custom-port,3306
custom-database,myapp
custom-username,botuser
custom-password,secretpass
```
## Multi-Agent Parameters ## Multi-Agent Parameters
### Agent-to-Agent (A2A) Communication ### Agent-to-Agent (A2A) Communication
@ -147,15 +160,35 @@ llm-model,mixtral-8x7b-32768
| `a2a-max-hops` | Maximum delegation chain depth | `5` | Number | | `a2a-max-hops` | Maximum delegation chain depth | `5` | Number |
| `a2a-retry-count` | Retry attempts on failure | `3` | Number | | `a2a-retry-count` | Retry attempts on failure | `3` | Number |
| `a2a-queue-size` | Maximum pending messages | `100` | Number | | `a2a-queue-size` | Maximum pending messages | `100` | Number |
| `a2a-protocol-version` | A2A protocol version | `1.0` | String |
| `a2a-persist-messages` | Persist A2A messages to database | `false` | Boolean |
### Bot Reflection ### Bot Reflection
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `reflection-enabled` | Enable bot self-analysis | `true` | Boolean | | `bot-reflection-enabled` | Enable bot self-analysis | `true` | Boolean |
| `reflection-interval` | Messages between reflections | `10` | Number | | `bot-reflection-interval` | Messages between reflections | `10` | Number |
| `reflection-min-messages` | Minimum messages before reflecting | `3` | Number | | `bot-reflection-prompt` | Custom reflection prompt | (none) | String |
| `reflection-model` | LLM model for reflection | `quality` | String | | `bot-reflection-types` | Reflection types to perform | `ConversationQuality` | Semicolon-separated |
| `reflection-store-insights` | Store insights in database | `true` | Boolean | | `bot-improvement-auto-apply` | Auto-apply suggested improvements | `false` | Boolean |
| `bot-improvement-threshold` | Score threshold for improvements (0-10) | `6.0` | Float |
#### Reflection Types
Available values for `bot-reflection-types`:
- `ConversationQuality` - Analyze conversation quality and user satisfaction
- `ResponseAccuracy` - Analyze response accuracy and relevance
- `ToolUsage` - Analyze tool usage effectiveness
- `KnowledgeRetrieval` - Analyze knowledge retrieval performance
- `Performance` - Analyze overall bot performance
Example:
```csv
bot-reflection-enabled,true
bot-reflection-interval,10
bot-reflection-types,ConversationQuality;ResponseAccuracy;ToolUsage
bot-improvement-auto-apply,false
bot-improvement-threshold,7.0
```
## Memory Parameters ## Memory Parameters
@ -173,54 +206,141 @@ llm-model,mixtral-8x7b-32768
| `episodic-summary-model` | Model for summarization | `fast` | String | | `episodic-summary-model` | Model for summarization | `fast` | String |
| `episodic-max-episodes` | Maximum episodes per user | `100` | Number | | `episodic-max-episodes` | Maximum episodes per user | `100` | Number |
| `episodic-retention-days` | Days to retain episodes | `365` | Number | | `episodic-retention-days` | Days to retain episodes | `365` | Number |
| `episodic-auto-summarize` | Enable automatic summarization | `true` | Boolean |
## Model Routing Parameters ## Model Routing Parameters
These parameters configure multi-model routing for different task types. Requires multiple llama.cpp server instances.
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `llm-models` | Available model aliases | `default` | Semicolon-separated |
| `model-routing-strategy` | Routing strategy (manual/auto/load-balanced/fallback) | `auto` | String | | `model-routing-strategy` | Routing strategy (manual/auto/load-balanced/fallback) | `auto` | String |
| `model-default` | Default model alias | `fast` | String | | `model-default` | Default model alias | `default` | String |
| `model-fast` | Model for fast/simple tasks | (configured) | Path/String | | `model-fast` | Model for fast/simple tasks | (configured) | Path/String |
| `model-quality` | Model for quality/complex tasks | (configured) | Path/String | | `model-quality` | Model for quality/complex tasks | (configured) | Path/String |
| `model-code` | Model for code generation | (configured) | Path/String | | `model-code` | Model for code generation | (configured) | Path/String |
| `model-fallback-enabled` | Enable automatic fallback | `true` | Boolean | | `model-fallback-enabled` | Enable automatic fallback | `true` | Boolean |
| `model-fallback-order` | Order to try on failure | `quality,fast,local` | String | | `model-fallback-order` | Order to try on failure | `quality,fast,local` | Comma-separated |
### Multi-Model Example
```csv
llm-models,default;fast;quality;code
llm-url,http://localhost:8081
model-routing-strategy,auto
model-default,fast
model-fallback-enabled,true
model-fallback-order,quality,fast
```
## Hybrid RAG Search Parameters ## Hybrid RAG Search Parameters
General Bots uses hybrid search combining **dense (embedding)** and **sparse (BM25 keyword)** search for optimal retrieval. The BM25 implementation is powered by [Tantivy](https://github.com/quickwit-oss/tantivy), a full-text search engine library similar to Apache Lucene.
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `rag-hybrid-enabled` | Enable hybrid dense+sparse search | `true` | Boolean | | `rag-hybrid-enabled` | Enable hybrid dense+sparse search | `true` | Boolean |
| `rag-dense-weight` | Weight for semantic results | `0.7` | Float (0-1) | | `rag-dense-weight` | Weight for semantic results | `0.7` | Float (0-1) |
| `rag-sparse-weight` | Weight for keyword results | `0.3` | Float (0-1) | | `rag-sparse-weight` | Weight for keyword results | `0.3` | Float (0-1) |
| `rag-reranker-enabled` | Enable LLM reranking | `false` | Boolean | | `rag-reranker-enabled` | Enable LLM reranking | `false` | Boolean |
| `rag-reranker-model` | Model for reranking | `quality` | String | | `rag-reranker-model` | Model for reranking | `cross-encoder/ms-marco-MiniLM-L-6-v2` | String |
| `rag-reranker-top-n` | Candidates for reranking | `20` | Number | | `rag-reranker-top-n` | Candidates for reranking | `20` | Number |
| `rag-top-k` | Results to return | `10` | Number | | `rag-max-results` | Maximum results to return | `10` | Number |
| `rag-min-score` | Minimum relevance score threshold | `0.0` | Float (0-1) |
| `rag-rrf-k` | RRF smoothing constant | `60` | Number | | `rag-rrf-k` | RRF smoothing constant | `60` | Number |
| `rag-cache-enabled` | Enable search result caching | `true` | Boolean | | `rag-cache-enabled` | Enable search result caching | `true` | Boolean |
| `rag-cache-ttl` | Cache time-to-live | `3600` | Seconds | | `rag-cache-ttl` | Cache time-to-live | `3600` | Seconds |
### BM25 (Sparse Search) Tuning ### BM25 Sparse Search (Tantivy)
BM25 is a keyword-based ranking algorithm that excels at finding exact term matches. It's powered by Tantivy when the `vectordb` feature is enabled.
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `bm25-k1` | Term saturation parameter | `1.2` | Float | | `bm25-enabled` | **Enable/disable BM25 sparse search** | `true` | Boolean |
| `bm25-b` | Length normalization | `0.75` | Float | | `bm25-k1` | Term frequency saturation (0.5-3.0 typical) | `1.2` | Float |
| `bm25-stemming` | Enable word stemming | `true` | Boolean | | `bm25-b` | Document length normalization (0.0-1.0) | `0.75` | Float |
| `bm25-stopwords` | Filter common words | `true` | Boolean | | `bm25-stemming` | Apply word stemming (running→run) | `true` | Boolean |
| `bm25-stopwords` | Filter common words (the, a, is) | `true` | Boolean |
### Switching Search Modes
**Hybrid Search (Default - Best for most use cases)**
```csv
bm25-enabled,true
rag-dense-weight,0.7
rag-sparse-weight,0.3
```
Uses both semantic understanding AND keyword matching. Best for general queries.
**Dense Only (Semantic Search)**
```csv
bm25-enabled,false
rag-dense-weight,1.0
rag-sparse-weight,0.0
```
Uses only embedding-based search. Faster, good for conceptual/semantic queries where exact words don't matter.
**Sparse Only (Keyword Search)**
```csv
bm25-enabled,true
rag-dense-weight,0.0
rag-sparse-weight,1.0
```
Uses only BM25 keyword matching. Good for exact term searches, technical documentation, or when embeddings aren't available.
### BM25 Parameter Tuning
The `k1` and `b` parameters control BM25 behavior:
- **`bm25-k1`** (Term Saturation): Controls how much additional term occurrences contribute to the score
- Lower values (0.5-1.0): Diminishing returns for repeated terms
- Higher values (1.5-2.0): More weight to documents with many term occurrences
- Default `1.2` works well for most content
- **`bm25-b`** (Length Normalization): Controls document length penalty
- `0.0`: No length penalty (long documents scored equally)
- `1.0`: Full length normalization (strongly penalizes long documents)
- Default `0.75` balances length fairness
**Tuning for specific content:**
```csv
# For short documents (tweets, titles)
bm25-b,0.3
# For long documents (articles, manuals)
bm25-b,0.9
# For code search (exact matches important)
bm25-k1,1.5
bm25-stemming,false
```
## Code Sandbox Parameters ## Code Sandbox Parameters
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
|-----------|-------------|---------|------| |-----------|-------------|---------|------|
| `sandbox-enabled` | Enable code sandbox | `true` | Boolean |
| `sandbox-runtime` | Isolation backend (lxc/docker/firecracker/process) | `lxc` | String | | `sandbox-runtime` | Isolation backend (lxc/docker/firecracker/process) | `lxc` | String |
| `sandbox-timeout` | Maximum execution time | `30` | Seconds | | `sandbox-timeout` | Maximum execution time | `30` | Seconds |
| `sandbox-memory-mb` | Memory limit | `512` | MB | | `sandbox-memory-mb` | Memory limit in megabytes | `256` | MB |
| `sandbox-cpu-percent` | CPU usage limit | `50` | Percent | | `sandbox-cpu-percent` | CPU usage limit | `50` | Percent |
| `sandbox-network` | Allow network access | `false` | Boolean | | `sandbox-network` | Allow network access | `false` | Boolean |
| `sandbox-python-packages` | Pre-installed Python packages | (none) | Comma-separated | | `sandbox-python-packages` | Pre-installed Python packages | (none) | Comma-separated |
| `sandbox-allowed-paths` | Accessible filesystem paths | `/data,/tmp` | Comma-separated | | `sandbox-allowed-paths` | Accessible filesystem paths | `/data,/tmp` | Comma-separated |
### Example: Python Sandbox
```csv
sandbox-enabled,true
sandbox-runtime,lxc
sandbox-timeout,60
sandbox-memory-mb,512
sandbox-cpu-percent,75
sandbox-network,false
sandbox-python-packages,numpy,pandas,requests,matplotlib
sandbox-allowed-paths,/data,/tmp,/uploads
```
## SSE Streaming Parameters ## SSE Streaming Parameters
| Parameter | Description | Default | Type | | Parameter | Description | Default | Type |
@ -229,14 +349,6 @@ llm-model,mixtral-8x7b-32768
| `sse-heartbeat` | Heartbeat interval | `30` | Seconds | | `sse-heartbeat` | Heartbeat interval | `30` | Seconds |
| `sse-max-connections` | Maximum concurrent connections | `1000` | Number | | `sse-max-connections` | Maximum concurrent connections | `1000` | Number |
## OpenAPI Tool Generation Parameters
| Parameter | Description | Default | Type |
|-----------|-------------|---------|------|
| `openapi-server` | OpenAPI spec URL for auto tool generation | Not set | URL |
| `openapi-auth-header` | Authentication header name | `Authorization` | String |
| `openapi-auth-value` | Authentication header value | Not set | String |
## Parameter Types ## Parameter Types
### Boolean ### Boolean
@ -251,6 +363,7 @@ Integer values, must be within valid ranges:
### Float ### Float
Decimal values: Decimal values:
- Thresholds: 0.0 to 1.0 - Thresholds: 0.0 to 1.0
- Weights: 0.0 to 1.0
### Path ### Path
File system paths: File system paths:
@ -271,6 +384,12 @@ Valid email format: `user@domain.com`
### Hex Color ### Hex Color
HTML color codes: `#RRGGBB` format HTML color codes: `#RRGGBB` format
### Semicolon-separated
Multiple values separated by semicolons: `value1;value2;value3`
### Comma-separated
Multiple values separated by commas: `value1,value2,value3`
## Required vs Optional ## Required vs Optional
### Always Required ### Always Required
@ -312,6 +431,7 @@ llm-server-cont-batching,true
llm-cache-semantic,true llm-cache-semantic,true
llm-cache-threshold,0.90 llm-cache-threshold,0.90
llm-server-parallel,8 llm-server-parallel,8
sse-max-connections,5000
``` ```
### For Low Memory ### For Low Memory
@ -321,6 +441,7 @@ llm-server-n-predict,512
llm-server-mlock,false llm-server-mlock,false
llm-server-no-mmap,false llm-server-no-mmap,false
llm-cache,false llm-cache,false
sandbox-memory-mb,128
``` ```
### For Multi-Agent Systems ### For Multi-Agent Systems
@ -328,9 +449,10 @@ llm-cache,false
a2a-enabled,true a2a-enabled,true
a2a-timeout,30 a2a-timeout,30
a2a-max-hops,5 a2a-max-hops,5
model-routing-strategy,auto a2a-retry-count,3
reflection-enabled,true a2a-persist-messages,true
reflection-interval,10 bot-reflection-enabled,true
bot-reflection-interval,10
user-memory-enabled,true user-memory-enabled,true
``` ```
@ -340,11 +462,25 @@ rag-hybrid-enabled,true
rag-dense-weight,0.7 rag-dense-weight,0.7
rag-sparse-weight,0.3 rag-sparse-weight,0.3
rag-reranker-enabled,true rag-reranker-enabled,true
rag-max-results,10
rag-min-score,0.3
rag-cache-enabled,true rag-cache-enabled,true
bm25-enabled,true
bm25-k1,1.2
bm25-b,0.75
```
### For Dense-Only Search (Faster)
```csv
bm25-enabled,false
rag-dense-weight,1.0
rag-sparse-weight,0.0
rag-max-results,10
``` ```
### For Code Execution ### For Code Execution
```csv ```csv
sandbox-enabled,true
sandbox-runtime,lxc sandbox-runtime,lxc
sandbox-timeout,30 sandbox-timeout,30
sandbox-memory-mb,512 sandbox-memory-mb,512
@ -360,3 +496,4 @@ sandbox-python-packages,numpy,pandas,requests
4. **Emails**: Must contain @ and domain 4. **Emails**: Must contain @ and domain
5. **Colors**: Must be valid hex format 5. **Colors**: Must be valid hex format
6. **Booleans**: Exactly `true` or `false` 6. **Booleans**: Exactly `true` or `false`
7. **Weights**: Must sum to 1.0 (e.g., `rag-dense-weight` + `rag-sparse-weight`)

View file

@ -159,6 +159,10 @@ pub struct A2AConfig {
pub protocol_version: String, pub protocol_version: String,
/// Enable message persistence /// Enable message persistence
pub persist_messages: bool, pub persist_messages: bool,
/// Retry attempts on failure
pub retry_count: u32,
/// Maximum pending messages in queue
pub queue_size: u32,
} }
impl Default for A2AConfig { impl Default for A2AConfig {
@ -169,6 +173,8 @@ impl Default for A2AConfig {
max_hops: 5, max_hops: 5,
protocol_version: "1.0".to_string(), protocol_version: "1.0".to_string(),
persist_messages: true, persist_messages: true,
retry_count: 3,
queue_size: 100,
} }
} }
} }
@ -211,6 +217,12 @@ pub fn load_a2a_config(state: &AppState, bot_id: Uuid) -> A2AConfig {
"a2a-persist-messages" => { "a2a-persist-messages" => {
config.persist_messages = row.config_value.to_lowercase() == "true"; config.persist_messages = row.config_value.to_lowercase() == "true";
} }
"a2a-retry-count" => {
config.retry_count = row.config_value.parse().unwrap_or(3);
}
"a2a-queue-size" => {
config.queue_size = row.config_value.parse().unwrap_or(100);
}
_ => {} _ => {}
} }
} }

View file

@ -1,5 +1,39 @@
/*****************************************************************************\
| ® |
| |
| |
| |
| |
| |
| General Bots Copyright (c) pragmatismo.com.br. All rights reserved. |
| Licensed under the AGPL-3.0. |
| |
| According to our dual licensing model, this program can be used either |
| under the terms of the GNU Affero General Public License, version 3, |
| or under a proprietary license. |
| |
| The texts of the GNU Affero General Public License with an additional |
| permission and of our proprietary license can be found at and |
| in the LICENSE file you have received along with this program. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY, without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| "General Bots" is a registered trademark of pragmatismo.com.br. |
| The licensing of the program under the AGPLv3 does not imply a |
| trademark license. Therefore any rights, title and interest in |
| our trademarks remain entirely with us. |
| |
\*****************************************************************************/
//! CARD keyword - Creates beautiful Instagram-style posts from prompts //! CARD keyword - Creates beautiful Instagram-style posts from prompts
//! //!
//! This module generates social media cards by combining AI-generated images
//! with AI-generated text. The LLM handles both image generation and text
//! composition, eliminating the need for local image processing.
//!
//! Syntax: //! Syntax:
//! CARD image_prompt, text_prompt TO variable //! CARD image_prompt, text_prompt TO variable
//! CARD image_prompt, text_prompt, style TO variable //! CARD image_prompt, text_prompt, style TO variable
@ -13,10 +47,9 @@
use crate::basic::runtime::{BasicRuntime, BasicValue}; use crate::basic::runtime::{BasicRuntime, BasicValue};
use crate::llm::LLMProvider; use crate::llm::LLMProvider;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage};
use imageproc::drawing::{draw_text_mut, text_size};
use rusttype::{Font, Scale};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -87,18 +120,7 @@ impl CardDimensions {
} }
} }
/// Text overlay configuration /// Text position configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextOverlay {
pub text: String,
pub font_size: f32,
pub color: [u8; 4],
pub position: TextPosition,
pub max_width_ratio: f32,
pub shadow: bool,
pub background: Option<[u8; 4]>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum TextPosition { pub enum TextPosition {
Top, Top,
@ -148,6 +170,7 @@ impl Default for CardConfig {
} }
/// CARD keyword implementation /// CARD keyword implementation
/// Uses LLM for both image generation and text composition
pub struct CardKeyword { pub struct CardKeyword {
llm_provider: Arc<dyn LLMProvider>, llm_provider: Arc<dyn LLMProvider>,
output_dir: String, output_dir: String,
@ -178,6 +201,9 @@ impl CardKeyword {
..Default::default() ..Default::default()
}; };
// Ensure output directory exists
fs::create_dir_all(&self.output_dir)?;
let mut results = Vec::with_capacity(card_count); let mut results = Vec::with_capacity(card_count);
for i in 0..card_count { for i in 0..card_count {
@ -201,16 +227,20 @@ impl CardKeyword {
// Step 1: Generate optimized text content using LLM // Step 1: Generate optimized text content using LLM
let text_content = self.generate_text_content(text_prompt, config).await?; let text_content = self.generate_text_content(text_prompt, config).await?;
// Step 2: Generate image using image generation API // Step 2: Create enhanced prompt that includes text overlay instructions
let base_image = self.generate_image(image_prompt, config).await?; let enhanced_image_prompt = self.create_card_prompt(image_prompt, &text_content, config);
// Step 3: Apply style and text overlay // Step 3: Generate image with text baked in via LLM image generation
let styled_image = self.apply_style_and_text(&base_image, &text_content, config)?; let image_bytes = self
.llm_provider
.generate_image(
&enhanced_image_prompt,
config.dimensions.width,
config.dimensions.height,
)
.await?;
// Step 4: Generate hashtags and caption // Step 4: Save the image
let (hashtags, caption) = self.generate_social_content(&text_content, config).await?;
// Step 5: Save the final image
let filename = format!( let filename = format!(
"card_{}_{}.png", "card_{}_{}.png",
chrono::Utc::now().format("%Y%m%d_%H%M%S"), chrono::Utc::now().format("%Y%m%d_%H%M%S"),
@ -218,7 +248,15 @@ impl CardKeyword {
); );
let image_path = format!("{}/{}", self.output_dir, filename); let image_path = format!("{}/{}", self.output_dir, filename);
styled_image.save(&image_path)?; // Ensure parent directory exists
if let Some(parent) = Path::new(&image_path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(&image_path, &image_bytes)?;
// Step 5: Generate hashtags and caption
let (hashtags, caption) = self.generate_social_content(&text_content, config).await?;
Ok(CardResult { Ok(CardResult {
image_path: image_path.clone(), image_path: image_path.clone(),
@ -276,30 +314,13 @@ Respond with ONLY the text content, nothing else."#,
} }
} }
/// Generate the base image /// Create an enhanced prompt for image generation that includes text overlay
async fn generate_image( fn create_card_prompt(
&self, &self,
image_prompt: &str, image_prompt: &str,
text_content: &str,
config: &CardConfig, config: &CardConfig,
) -> Result<DynamicImage> { ) -> String {
let enhanced_prompt = self.enhance_image_prompt(image_prompt, config);
// Call image generation service
let image_bytes = self
.llm_provider
.generate_image(
&enhanced_prompt,
config.dimensions.width,
config.dimensions.height,
)
.await?;
let image = image::load_from_memory(&image_bytes)?;
Ok(image)
}
/// Enhance the image prompt based on style
fn enhance_image_prompt(&self, base_prompt: &str, config: &CardConfig) -> String {
let style_modifiers = match config.style { let style_modifiers = match config.style {
CardStyle::Minimal => { CardStyle::Minimal => {
"minimalist, clean, simple composition, lots of negative space, muted colors" "minimalist, clean, simple composition, lots of negative space, muted colors"
@ -315,175 +336,45 @@ Respond with ONLY the text content, nothing else."#,
CardStyle::Modern => "modern, trendy, instagram aesthetic, high quality", CardStyle::Modern => "modern, trendy, instagram aesthetic, high quality",
}; };
format!( let text_position = match config.text_position {
"{}, {}, perfect for Instagram, professional quality, 4K, highly detailed", TextPosition::Top => "at the top",
base_prompt, style_modifiers TextPosition::Center => "in the center",
) TextPosition::Bottom => "at the bottom",
} TextPosition::TopLeft => "in the top left corner",
TextPosition::TopRight => "in the top right corner",
/// Apply style effects and text overlay to the image TextPosition::BottomLeft => "in the bottom left corner",
fn apply_style_and_text( TextPosition::BottomRight => "in the bottom right corner",
&self,
image: &DynamicImage,
text: &str,
config: &CardConfig,
) -> Result<DynamicImage> {
let mut rgba_image = image.to_rgba8();
// Apply style-specific filters
self.apply_style_filter(&mut rgba_image, &config.style);
// Add text overlay
self.add_text_overlay(&mut rgba_image, text, config)?;
// Add watermark if configured
if let Some(ref watermark) = config.brand_watermark {
self.add_watermark(&mut rgba_image, watermark)?;
}
Ok(DynamicImage::ImageRgba8(rgba_image))
}
/// Apply style-specific image filters
fn apply_style_filter(&self, image: &mut RgbaImage, style: &CardStyle) {
match style {
CardStyle::Dark => {
// Darken and increase contrast
for pixel in image.pixels_mut() {
pixel[0] = (pixel[0] as f32 * 0.7) as u8;
pixel[1] = (pixel[1] as f32 * 0.7) as u8;
pixel[2] = (pixel[2] as f32 * 0.7) as u8;
}
}
CardStyle::Light => {
// Brighten slightly
for pixel in image.pixels_mut() {
pixel[0] = ((pixel[0] as f32 * 1.1).min(255.0)) as u8;
pixel[1] = ((pixel[1] as f32 * 1.1).min(255.0)) as u8;
pixel[2] = ((pixel[2] as f32 * 1.1).min(255.0)) as u8;
}
}
CardStyle::Polaroid => {
// Add warm vintage tint
for pixel in image.pixels_mut() {
pixel[0] = ((pixel[0] as f32 * 1.05).min(255.0)) as u8;
pixel[1] = ((pixel[1] as f32 * 0.95).min(255.0)) as u8;
pixel[2] = ((pixel[2] as f32 * 0.85).min(255.0)) as u8;
}
}
CardStyle::Vibrant => {
// Increase saturation
for pixel in image.pixels_mut() {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
let avg = (r + g + b) / 3.0;
let factor = 1.3;
pixel[0] = ((r - avg) * factor + avg).clamp(0.0, 255.0) as u8;
pixel[1] = ((g - avg) * factor + avg).clamp(0.0, 255.0) as u8;
pixel[2] = ((b - avg) * factor + avg).clamp(0.0, 255.0) as u8;
}
}
_ => {}
}
}
/// Add text overlay to the image
fn add_text_overlay(
&self,
image: &mut RgbaImage,
text: &str,
config: &CardConfig,
) -> Result<()> {
let (width, height) = (image.width(), image.height());
// Load font (embedded or from file)
let font_data = include_bytes!("../../../assets/fonts/Inter-Bold.ttf");
let font = Font::try_from_bytes(font_data as &[u8])
.ok_or_else(|| anyhow!("Failed to load font"))?;
// Calculate font size based on image dimensions and text length
let base_size = (width as f32 * 0.08).min(height as f32 * 0.1);
let scale = Scale::uniform(base_size);
// Calculate text position
let (text_width, text_height) = text_size(scale, &font, text);
let (x, y) = self.calculate_text_position(
width,
height,
text_width as u32,
text_height as u32,
&config.text_position,
);
// Draw text shadow for better readability
let shadow_color = Rgba([0u8, 0u8, 0u8, 180u8]);
draw_text_mut(image, shadow_color, x + 3, y + 3, scale, &font, text);
// Draw main text
let text_color = match config.style {
CardStyle::Dark => Rgba([255u8, 255u8, 255u8, 255u8]),
CardStyle::Light => Rgba([30u8, 30u8, 30u8, 255u8]),
_ => Rgba([255u8, 255u8, 255u8, 255u8]),
}; };
draw_text_mut(image, text_color, x, y, scale, &font, text);
Ok(()) let text_color = match config.style {
} CardStyle::Dark => "white",
CardStyle::Light => "dark gray or black",
_ => "white with a subtle shadow",
};
/// Calculate text position based on configuration format!(
fn calculate_text_position( r#"Create an Instagram-ready image with the following specifications:
&self,
img_width: u32,
img_height: u32,
text_width: u32,
text_height: u32,
position: &TextPosition,
) -> (i32, i32) {
let padding = (img_width as f32 * 0.05) as i32;
match position { Background/Scene: {}
TextPosition::Top => ( Style: {}, perfect for Instagram, professional quality, 4K, highly detailed
((img_width - text_width) / 2) as i32,
padding + text_height as i32,
),
TextPosition::Center => (
((img_width - text_width) / 2) as i32,
((img_height - text_height) / 2) as i32,
),
TextPosition::Bottom => (
((img_width - text_width) / 2) as i32,
(img_height - text_height) as i32 - padding,
),
TextPosition::TopLeft => (padding, padding + text_height as i32),
TextPosition::TopRight => (
(img_width - text_width) as i32 - padding,
padding + text_height as i32,
),
TextPosition::BottomLeft => (padding, (img_height - text_height) as i32 - padding),
TextPosition::BottomRight => (
(img_width - text_width) as i32 - padding,
(img_height - text_height) as i32 - padding,
),
}
}
/// Add brand watermark Text Overlay Requirements:
fn add_watermark(&self, image: &mut RgbaImage, watermark: &str) -> Result<()> { - Display the text "{}" {} of the image
let font_data = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - Use bold, modern typography
let font = Font::try_from_bytes(font_data as &[u8]) - Text color should be {} for readability
.ok_or_else(|| anyhow!("Failed to load font"))?; - Add a subtle text shadow or background blur behind text for contrast
- Leave appropriate padding around text
let scale = Scale::uniform(image.width() as f32 * 0.025); The image should be {} x {} pixels, optimized for social media.
let color = Rgba([255u8, 255u8, 255u8, 128u8]); Make the text an integral part of the design, not just overlaid."#,
image_prompt,
let padding = 20i32; style_modifiers,
let x = padding; text_content,
let y = (image.height() - 30) as i32; text_position,
text_color,
draw_text_mut(image, color, x, y, scale, &font, watermark); config.dimensions.width,
config.dimensions.height
Ok(()) )
} }
/// Generate hashtags and caption for the post /// Generate hashtags and caption for the post
@ -516,11 +407,22 @@ HASHTAGS: tag1, tag2, tag3, tag4, tag5"#,
let mut hashtags = Vec::new(); let mut hashtags = Vec::new();
for line in response.lines() { for line in response.lines() {
if line.starts_with("CAPTION:") { let line_trimmed = line.trim();
caption = line.trim_start_matches("CAPTION:").trim().to_string(); if line_trimmed.starts_with("CAPTION:") {
} else if line.starts_with("HASHTAGS:") { caption = line_trimmed
let tags = line.trim_start_matches("HASHTAGS:").trim(); .trim_start_matches("CAPTION:")
hashtags = tags.split(',').map(|t| format!("#{}", t.trim())).collect(); .trim()
.to_string();
} else if line_trimmed.starts_with("HASHTAGS:") {
let tags = line_trimmed.trim_start_matches("HASHTAGS:").trim();
hashtags = tags
.split(',')
.map(|t| {
let tag = t.trim().trim_start_matches('#');
format!("#{}", tag)
})
.filter(|t| t.len() > 1)
.collect();
} }
} }
@ -550,27 +452,36 @@ pub fn register_card_keyword(runtime: &mut BasicRuntime, llm_provider: Arc<dyn L
let style = args.get(2).map(|v| v.as_string()).transpose()?; let style = args.get(2).map(|v| v.as_string()).transpose()?;
let count = args let count = args
.get(3) .get(3)
.map(|v| v.as_number().map(|n| n as usize)) .map(|v| v.as_integer().map(|i| i as usize))
.transpose()?; .transpose()?;
let kw = keyword.lock().await; let keyword = keyword.lock().await;
let results = kw let results = keyword
.execute(&image_prompt, &text_prompt, style.as_deref(), count) .execute(&image_prompt, &text_prompt, style.as_deref(), count)
.await?; .await?;
// Convert results to BasicValue // Convert results to BasicValue
let value = if results.len() == 1 { let result_values: Vec<BasicValue> = results
BasicValue::Object(serde_json::to_value(&results[0])?) .into_iter()
} else { .map(|r| {
BasicValue::Array( BasicValue::Object(serde_json::json!({
results "image_path": r.image_path,
.into_iter() "image_url": r.image_url,
.map(|r| BasicValue::Object(serde_json::to_value(&r).unwrap())) "text": r.text_content,
.collect(), "hashtags": r.hashtags,
) "caption": r.caption,
}; "style": r.style,
"width": r.dimensions.0,
"height": r.dimensions.1,
}))
})
.collect();
Ok(value) if result_values.len() == 1 {
Ok(result_values.into_iter().next().unwrap())
} else {
Ok(BasicValue::Array(result_values))
}
}) })
}); });
} }
@ -583,19 +494,27 @@ mod tests {
fn test_card_style_from_string() { fn test_card_style_from_string() {
assert!(matches!(CardStyle::from("minimal"), CardStyle::Minimal)); assert!(matches!(CardStyle::from("minimal"), CardStyle::Minimal));
assert!(matches!(CardStyle::from("VIBRANT"), CardStyle::Vibrant)); assert!(matches!(CardStyle::from("VIBRANT"), CardStyle::Vibrant));
assert!(matches!(CardStyle::from("dark"), CardStyle::Dark));
assert!(matches!(CardStyle::from("unknown"), CardStyle::Modern)); assert!(matches!(CardStyle::from("unknown"), CardStyle::Modern));
} }
#[test] #[test]
fn test_card_dimensions() { fn test_card_dimensions_for_style() {
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.width, 1080); let story_dims = CardDimensions::for_style(&CardStyle::Story);
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.height, 1080); assert_eq!(story_dims.width, 1080);
assert_eq!(CardDimensions::INSTAGRAM_STORY.height, 1920); assert_eq!(story_dims.height, 1920);
let square_dims = CardDimensions::for_style(&CardStyle::Modern);
assert_eq!(square_dims.width, 1080);
assert_eq!(square_dims.height, 1080);
} }
#[test] #[test]
fn test_text_position_calculation() { fn test_card_config_default() {
// Create a mock keyword for testing let config = CardConfig::default();
// In real tests, we'd use a mock LLM provider assert!(matches!(config.style, CardStyle::Modern));
assert!(config.include_hashtags);
assert!(config.include_caption);
assert!(config.brand_watermark.is_none());
} }
} }

View file

@ -12,10 +12,12 @@
//! ```csv //! ```csv
//! sandbox-enabled,true //! sandbox-enabled,true
//! sandbox-timeout,30 //! sandbox-timeout,30
//! sandbox-memory-limit,256 //! sandbox-memory-mb,256
//! sandbox-cpu-limit,50 //! sandbox-cpu-percent,50
//! sandbox-network-enabled,false //! sandbox-network,false
//! sandbox-runtime,lxc //! sandbox-runtime,lxc
//! sandbox-python-packages,numpy,pandas,requests
//! sandbox-allowed-paths,/data,/tmp
//! ``` //! ```
use crate::shared::models::UserSession; use crate::shared::models::UserSession;
@ -125,10 +127,10 @@ pub struct SandboxConfig {
pub work_dir: String, pub work_dir: String,
/// Additional environment variables /// Additional environment variables
pub env_vars: HashMap<String, String>, pub env_vars: HashMap<String, String>,
/// Allowed file paths for read access /// Allowed file paths for access
pub allowed_read_paths: Vec<String>, pub allowed_paths: Vec<String>,
/// Allowed file paths for write access /// Pre-installed Python packages
pub allowed_write_paths: Vec<String>, pub python_packages: Vec<String>,
} }
impl Default for SandboxConfig { impl Default for SandboxConfig {
@ -142,8 +144,8 @@ impl Default for SandboxConfig {
network_enabled: false, network_enabled: false,
work_dir: "/tmp/gb-sandbox".to_string(), work_dir: "/tmp/gb-sandbox".to_string(),
env_vars: HashMap::new(), env_vars: HashMap::new(),
allowed_read_paths: vec![], allowed_paths: vec!["/data".to_string(), "/tmp".to_string()],
allowed_write_paths: vec![], python_packages: vec![],
} }
} }
} }
@ -181,15 +183,32 @@ impl SandboxConfig {
"sandbox-timeout" => { "sandbox-timeout" => {
config.timeout_seconds = row.config_value.parse().unwrap_or(30); config.timeout_seconds = row.config_value.parse().unwrap_or(30);
} }
"sandbox-memory-limit" => { // Support both old and new parameter names for backward compatibility
"sandbox-memory-mb" | "sandbox-memory-limit" => {
config.memory_limit_mb = row.config_value.parse().unwrap_or(256); config.memory_limit_mb = row.config_value.parse().unwrap_or(256);
} }
"sandbox-cpu-limit" => { "sandbox-cpu-percent" | "sandbox-cpu-limit" => {
config.cpu_limit_percent = row.config_value.parse().unwrap_or(50); config.cpu_limit_percent = row.config_value.parse().unwrap_or(50);
} }
"sandbox-network-enabled" => { "sandbox-network" | "sandbox-network-enabled" => {
config.network_enabled = row.config_value.to_lowercase() == "true"; config.network_enabled = row.config_value.to_lowercase() == "true";
} }
"sandbox-python-packages" => {
config.python_packages = row
.config_value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"sandbox-allowed-paths" => {
config.allowed_paths = row
.config_value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
_ => {} _ => {}
} }
} }

View file

@ -37,10 +37,12 @@
use crate::shared::models::UserSession; use crate::shared::models::UserSession;
use crate::shared::state::AppState; use crate::shared::state::AppState;
use image::Luma;
use log::{error, trace}; use log::{error, trace};
use png::{BitDepth, ColorType, Encoder};
use qrcode::QrCode; use qrcode::QrCode;
use rhai::{Dynamic, Engine}; use rhai::{Dynamic, Engine};
use std::fs::File;
use std::io::BufWriter;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@ -240,8 +242,29 @@ fn execute_qr_code_generation(
// Generate QR code // Generate QR code
let code = QrCode::new(data.as_bytes())?; let code = QrCode::new(data.as_bytes())?;
// Render to image // Get the QR code as a matrix of bools
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build(); let matrix = code.to_colors();
let qr_width = code.width();
// Calculate scale factor to reach target size
let scale = (size as usize) / qr_width;
let actual_size = qr_width * scale;
// Create grayscale pixel buffer
let mut pixels: Vec<u8> = Vec::with_capacity(actual_size * actual_size);
for y in 0..actual_size {
for x in 0..actual_size {
let qr_x = x / scale;
let qr_y = y / scale;
let idx = qr_y * qr_width + qr_x;
let is_dark = matrix
.get(idx)
.map(|c| *c == qrcode::Color::Dark)
.unwrap_or(false);
pixels.push(if is_dark { 0 } else { 255 });
}
}
// Determine output path // Determine output path
let data_dir = state let data_dir = state
@ -274,8 +297,16 @@ fn execute_qr_code_generation(
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
// Save image // Save as PNG using png crate directly
image.save(&final_path)?; let file = File::create(&final_path)?;
let ref mut w = BufWriter::new(file);
let mut encoder = Encoder::new(w, actual_size as u32, actual_size as u32);
encoder.set_color(ColorType::Grayscale);
encoder.set_depth(BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&pixels)?;
trace!("QR code generated: {}", final_path); trace!("QR code generated: {}", final_path);
Ok(final_path) Ok(final_path)
@ -289,70 +320,113 @@ pub fn generate_qr_code_colored(
background: [u8; 3], background: [u8; 3],
output_path: &str, output_path: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use image::{Rgb, RgbImage};
let code = QrCode::new(data.as_bytes())?; let code = QrCode::new(data.as_bytes())?;
let qr_image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
// Convert to RGB with custom colors // Get the QR code as a matrix of bools
let mut rgb_image = RgbImage::new(qr_image.width(), qr_image.height()); let matrix = code.to_colors();
let qr_width = code.width();
for (x, y, pixel) in qr_image.enumerate_pixels() { // Calculate scale factor to reach target size
let color = if pixel[0] == 0 { let scale = (size as usize) / qr_width;
Rgb(foreground) let actual_size = qr_width * scale;
} else {
Rgb(background) // Create RGB pixel buffer (3 bytes per pixel)
}; let mut pixels: Vec<u8> = Vec::with_capacity(actual_size * actual_size * 3);
rgb_image.put_pixel(x, y, color);
for y in 0..actual_size {
for x in 0..actual_size {
let qr_x = x / scale;
let qr_y = y / scale;
let idx = qr_y * qr_width + qr_x;
let is_dark = matrix
.get(idx)
.map(|c| *c == qrcode::Color::Dark)
.unwrap_or(false);
let color = if is_dark { foreground } else { background };
pixels.extend_from_slice(&color);
}
} }
rgb_image.save(output_path)?; // Save as PNG
let file = File::create(output_path)?;
let ref mut w = BufWriter::new(file);
let mut encoder = Encoder::new(w, actual_size as u32, actual_size as u32);
encoder.set_color(ColorType::Rgb);
encoder.set_depth(BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&pixels)?;
Ok(output_path.to_string()) Ok(output_path.to_string())
} }
/// Generate QR code with logo overlay /// Generate QR code with logo overlay
/// Note: Logo overlay requires the image crate. This simplified version
/// generates a QR code with a white center area where a logo can be placed manually.
pub fn generate_qr_code_with_logo( pub fn generate_qr_code_with_logo(
data: &str, data: &str,
size: u32, size: u32,
logo_path: &str, _logo_path: &str,
output_path: &str, output_path: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use image::{imageops, DynamicImage, Rgba, RgbaImage};
// Generate QR code with higher error correction for logo overlay // Generate QR code with higher error correction for logo overlay
let code = QrCode::with_error_correction_level(data.as_bytes(), qrcode::EcLevel::H)?; let code = QrCode::with_error_correction_level(data.as_bytes(), qrcode::EcLevel::H)?;
let qr_image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
// Convert to RGBA // Get the QR code as a matrix
let mut rgba_image = RgbaImage::new(qr_image.width(), qr_image.height()); let matrix = code.to_colors();
for (x, y, pixel) in qr_image.enumerate_pixels() { let qr_width = code.width();
let color = if pixel[0] == 0 {
Rgba([0, 0, 0, 255]) // Calculate scale factor
} else { let scale = (size as usize) / qr_width;
Rgba([255, 255, 255, 255]) let actual_size = qr_width * scale;
};
rgba_image.put_pixel(x, y, color); // Calculate logo area (center 20% of the QR code)
let logo_size = actual_size / 5;
let logo_start = (actual_size - logo_size) / 2;
let logo_end = logo_start + logo_size;
// Create RGBA pixel buffer (4 bytes per pixel)
let mut pixels: Vec<u8> = Vec::with_capacity(actual_size * actual_size * 4);
for y in 0..actual_size {
for x in 0..actual_size {
// Check if we're in the logo area
if x >= logo_start && x < logo_end && y >= logo_start && y < logo_end {
// White background for logo area
pixels.extend_from_slice(&[255, 255, 255, 255]);
} else {
let qr_x = x / scale;
let qr_y = y / scale;
let idx = qr_y * qr_width + qr_x;
let is_dark = matrix
.get(idx)
.map(|c| *c == qrcode::Color::Dark)
.unwrap_or(false);
if is_dark {
pixels.extend_from_slice(&[0, 0, 0, 255]);
} else {
pixels.extend_from_slice(&[255, 255, 255, 255]);
}
}
}
} }
// Load and resize logo // Save as PNG
let logo = image::open(logo_path)?; let file = File::create(output_path)?;
let logo_size = size / 5; // Logo should be about 20% of QR code size let ref mut w = BufWriter::new(file);
let resized_logo = logo.resize(logo_size, logo_size, imageops::FilterType::Lanczos3);
// Calculate center position let mut encoder = Encoder::new(w, actual_size as u32, actual_size as u32);
let center_x = (rgba_image.width() - resized_logo.width()) / 2; encoder.set_color(ColorType::Rgba);
let center_y = (rgba_image.height() - resized_logo.height()) / 2; encoder.set_depth(BitDepth::Eight);
// Overlay logo let mut writer = encoder.write_header()?;
let mut final_image = DynamicImage::ImageRgba8(rgba_image); writer.write_image_data(&pixels)?;
imageops::overlay(
&mut final_image, // Note: Logo overlay not supported without image crate
&resized_logo, // The QR code has a white center area where a logo can be placed manually
center_x.into(), trace!("QR code with logo placeholder generated: {}", output_path);
center_y.into(),
);
final_image.save(output_path)?;
Ok(output_path.to_string()) Ok(output_path.to_string())
} }

View file

@ -527,58 +527,28 @@ pub async fn create_table_on_external_db(
} }
} }
#[cfg(feature = "dynamic-db")]
async fn create_table_mysql(
connection_string: &str,
sql: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
use sqlx::mysql::MySqlPoolOptions;
use sqlx::Executor;
let pool = MySqlPoolOptions::new()
.max_connections(1)
.connect(connection_string)
.await?;
pool.execute(sql).await?;
info!("MySQL table created successfully");
Ok(())
}
#[cfg(not(feature = "dynamic-db"))]
async fn create_table_mysql( async fn create_table_mysql(
_connection_string: &str, _connection_string: &str,
_sql: &str, _sql: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> { ) -> Result<(), Box<dyn Error + Send + Sync>> {
Err("MySQL support requires the 'dynamic-db' feature".into()) // MySQL support requires diesel mysql_backend feature which pulls in problematic dependencies
// Use PostgreSQL instead, or implement via raw SQL if needed
Err("MySQL support is disabled. Please use PostgreSQL for dynamic tables.".into())
} }
#[cfg(feature = "dynamic-db")]
async fn create_table_postgres( async fn create_table_postgres(
connection_string: &str, connection_string: &str,
sql: &str, sql: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> { ) -> Result<(), Box<dyn Error + Send + Sync>> {
use sqlx::postgres::PgPoolOptions; use diesel::pg::PgConnection;
use sqlx::Executor; use diesel::prelude::*;
let pool = PgPoolOptions::new() let mut conn = PgConnection::establish(connection_string)?;
.max_connections(1) diesel::sql_query(sql).execute(&mut conn)?;
.connect(connection_string)
.await?;
pool.execute(sql).await?;
info!("PostgreSQL table created successfully"); info!("PostgreSQL table created successfully");
Ok(()) Ok(())
} }
#[cfg(not(feature = "dynamic-db"))]
async fn create_table_postgres(
_connection_string: &str,
_sql: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
Err("PostgreSQL dynamic table support requires the 'dynamic-db' feature".into())
}
/// Process TABLE definitions during .bas file compilation /// Process TABLE definitions during .bas file compilation
pub fn process_table_definitions( pub fn process_table_definitions(
state: Arc<AppState>, state: Arc<AppState>,

View file

@ -8,7 +8,9 @@ use aws_sdk_s3::Client;
use chrono; use chrono;
use log::{error, info, trace, warn}; use log::{error, info, trace, warn};
use rand::distr::Alphanumeric; use rand::distr::Alphanumeric;
use rcgen::{BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, IsCa}; use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair,
};
use std::fs; use std::fs;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -709,51 +711,39 @@ meet IN A 127.0.0.1
let ca_cert_path = cert_dir.join("ca/ca.crt"); let ca_cert_path = cert_dir.join("ca/ca.crt");
let ca_key_path = cert_dir.join("ca/ca.key"); let ca_key_path = cert_dir.join("ca/ca.key");
let ca_cert = if ca_cert_path.exists() && ca_key_path.exists() { // CA params for issuer creation
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, "BR");
dn.push(DnType::OrganizationName, "BotServer");
dn.push(DnType::CommonName, "BotServer CA");
ca_params.distinguished_name = dn;
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(3650);
let ca_key_pair: KeyPair = if ca_cert_path.exists() && ca_key_path.exists() {
info!("Using existing CA certificate"); info!("Using existing CA certificate");
// Load existing CA key and regenerate params // Load existing CA key
let key_pem = fs::read_to_string(&ca_key_path)?; let key_pem = fs::read_to_string(&ca_key_path)?;
let key_pair = rcgen::KeyPair::from_pem(&key_pem)?; KeyPair::from_pem(&key_pem)?
// Recreate CA params with the loaded key
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_pair = Some(key_pair);
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, "BR");
dn.push(DnType::OrganizationName, "BotServer");
dn.push(DnType::CommonName, "BotServer CA");
ca_params.distinguished_name = dn;
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(3650);
Certificate::from_params(ca_params)?
} else { } else {
info!("Generating new CA certificate"); info!("Generating new CA certificate");
// Generate new CA let key_pair = KeyPair::generate()?;
let mut ca_params = CertificateParams::default(); let cert = ca_params.self_signed(&key_pair)?;
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, "BR");
dn.push(DnType::OrganizationName, "BotServer");
dn.push(DnType::CommonName, "BotServer CA");
ca_params.distinguished_name = dn;
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(3650);
let ca_cert = Certificate::from_params(ca_params)?;
// Save CA certificate and key // Save CA certificate and key
fs::write(&ca_cert_path, ca_cert.serialize_pem()?)?; fs::write(&ca_cert_path, cert.pem())?;
fs::write(&ca_key_path, ca_cert.serialize_private_key_pem())?; fs::write(&ca_key_path, key_pair.serialize_pem())?;
ca_cert key_pair
}; };
// Create issuer from CA params and key
let ca_issuer = Issuer::from_params(&ca_params, &ca_key_pair);
// Services that need certificates // Services that need certificates
let services = vec![ let services = vec![
("api", vec!["localhost", "127.0.0.1", "api.botserver.local"]), ("api", vec!["localhost", "127.0.0.1", "api.botserver.local"]),
@ -847,15 +837,15 @@ meet IN A 127.0.0.1
for san in sans { for san in sans {
params params
.subject_alt_names .subject_alt_names
.push(rcgen::SanType::DnsName(san.to_string())); .push(rcgen::SanType::DnsName(san.to_string().try_into()?));
} }
let cert = Certificate::from_params(params)?; let key_pair = KeyPair::generate()?;
let cert_pem = cert.serialize_pem_with_signer(&ca_cert)?; let cert = params.signed_by(&key_pair, &ca_issuer)?;
// Save certificate and key // Save certificate and key
fs::write(cert_path, cert_pem)?; fs::write(cert_path, cert.pem())?;
fs::write(key_path, cert.serialize_private_key_pem())?; fs::write(key_path, key_pair.serialize_pem())?;
// Copy CA cert to service directory for easy access // Copy CA cert to service directory for easy access
fs::copy(&ca_cert_path, service_dir.join("ca.crt"))?; fs::copy(&ca_cert_path, service_dir.join("ca.crt"))?;

View file

@ -1,3 +1,11 @@
pub mod model_routing_config;
pub mod sse_config;
pub mod user_memory_config;
pub use model_routing_config::{ModelRoutingConfig, RoutingStrategy, TaskType};
pub use sse_config::SseConfig;
pub use user_memory_config::UserMemoryConfig;
use crate::shared::utils::DbPool; use crate::shared::utils::DbPool;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, PooledConnection}; use diesel::r2d2::{ConnectionManager, PooledConnection};
@ -37,6 +45,215 @@ pub struct EmailConfig {
pub smtp_server: String, pub smtp_server: String,
pub smtp_port: u16, pub smtp_port: u16,
} }
/// Custom database configuration for BASIC keywords (MariaDB, MySQL, etc.)
/// Loaded from config.csv parameters: custom-server, custom-port, custom-database, custom-username, custom-password
#[derive(Clone, Debug, Default)]
pub struct CustomDatabaseConfig {
pub server: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
}
impl CustomDatabaseConfig {
/// Load custom database configuration from bot-level config.csv parameters
pub fn from_bot_config(
pool: &DbPool,
target_bot_id: &Uuid,
) -> Result<Option<Self>, diesel::result::Error> {
use crate::shared::models::schema::bot_configuration::dsl::*;
let mut conn = pool.get().map_err(|e| {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UnableToSendCommand,
Box::new(e.to_string()),
)
})?;
// Check if custom database is configured
let database: Option<String> = bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq("custom-database"))
.select(config_value)
.first::<String>(&mut conn)
.ok()
.filter(|s| !s.is_empty());
let database = match database {
Some(db) => db,
None => return Ok(None), // No custom database configured
};
let server: String = bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq("custom-server"))
.select(config_value)
.first::<String>(&mut conn)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "localhost".to_string());
let port: u16 = bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq("custom-port"))
.select(config_value)
.first::<String>(&mut conn)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3306);
let username: String = bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq("custom-username"))
.select(config_value)
.first::<String>(&mut conn)
.ok()
.unwrap_or_default();
let password: String = bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq("custom-password"))
.select(config_value)
.first::<String>(&mut conn)
.ok()
.unwrap_or_default();
Ok(Some(CustomDatabaseConfig {
server,
port,
database,
username,
password,
}))
}
/// Build a connection string for MariaDB/MySQL
pub fn connection_string(&self) -> String {
format!(
"mysql://{}:{}@{}:{}/{}",
self.username, self.password, self.server, self.port, self.database
)
}
/// Check if the configuration is valid (has required fields)
pub fn is_valid(&self) -> bool {
!self.database.is_empty() && !self.server.is_empty()
}
}
impl EmailConfig {
/// Load email configuration from bot-level config.csv parameters
/// Parameters: email-from, email-server, email-port, email-user, email-pass
pub fn from_bot_config(
pool: &DbPool,
target_bot_id: &Uuid,
) -> Result<Self, diesel::result::Error> {
let mut conn = pool.get().map_err(|e| {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UnableToSendCommand,
Box::new(e.to_string()),
)
})?;
// Helper to get config value
fn get_config_value(
conn: &mut diesel::r2d2::PooledConnection<
diesel::r2d2::ConnectionManager<diesel::PgConnection>,
>,
target_bot_id: &Uuid,
key: &str,
default: &str,
) -> String {
use crate::shared::models::schema::bot_configuration::dsl::*;
bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq(key))
.select(config_value)
.first::<String>(conn)
.unwrap_or_else(|_| default.to_string())
}
fn get_port_value(
conn: &mut diesel::r2d2::PooledConnection<
diesel::r2d2::ConnectionManager<diesel::PgConnection>,
>,
target_bot_id: &Uuid,
key: &str,
default: u16,
) -> u16 {
use crate::shared::models::schema::bot_configuration::dsl::*;
bot_configuration
.filter(bot_id.eq(target_bot_id))
.filter(config_key.eq(key))
.select(config_value)
.first::<String>(conn)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
// Support both old ENV-style and new config.csv style parameter names
let new_smtp_server = get_config_value(&mut conn, target_bot_id, "email-server", "");
let smtp_server = if !new_smtp_server.is_empty() {
new_smtp_server
} else {
get_config_value(
&mut conn,
target_bot_id,
"EMAIL_SMTP_SERVER",
"smtp.gmail.com",
)
};
let new_smtp_port = get_port_value(&mut conn, target_bot_id, "email-port", 0);
let smtp_port = if new_smtp_port > 0 {
new_smtp_port
} else {
get_port_value(&mut conn, target_bot_id, "EMAIL_SMTP_PORT", 587)
};
let new_from = get_config_value(&mut conn, target_bot_id, "email-from", "");
let from = if !new_from.is_empty() {
new_from
} else {
get_config_value(&mut conn, target_bot_id, "EMAIL_FROM", "")
};
let new_user = get_config_value(&mut conn, target_bot_id, "email-user", "");
let username = if !new_user.is_empty() {
new_user
} else {
get_config_value(&mut conn, target_bot_id, "EMAIL_USERNAME", "")
};
let new_pass = get_config_value(&mut conn, target_bot_id, "email-pass", "");
let password = if !new_pass.is_empty() {
new_pass
} else {
get_config_value(&mut conn, target_bot_id, "EMAIL_PASSWORD", "")
};
let server = get_config_value(
&mut conn,
target_bot_id,
"EMAIL_IMAP_SERVER",
"imap.gmail.com",
);
let port = get_port_value(&mut conn, target_bot_id, "EMAIL_IMAP_PORT", 993);
Ok(EmailConfig {
server,
port,
username,
password,
from,
smtp_server,
smtp_port,
})
}
}
impl AppConfig { impl AppConfig {
pub fn from_database(pool: &DbPool) -> Result<Self, diesel::result::Error> { pub fn from_database(pool: &DbPool) -> Result<Self, diesel::result::Error> {
use crate::shared::models::schema::bot_configuration::dsl::*; use crate::shared::models::schema::bot_configuration::dsl::*;

View file

@ -5,8 +5,7 @@
use anyhow::Result; use anyhow::Result;
use rcgen::{ use rcgen::{
BasicConstraints, Certificate as RcgenCertificate, CertificateParams, DistinguishedName, BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair, SanType,
DnType, IsCa, KeyPair, SanType,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
@ -88,16 +87,18 @@ impl Default for CaConfig {
/// Certificate Authority Manager /// Certificate Authority Manager
pub struct CaManager { pub struct CaManager {
config: CaConfig, config: CaConfig,
ca_cert: Option<RcgenCertificate>, ca_params: Option<CertificateParams>,
intermediate_cert: Option<RcgenCertificate>, ca_key: Option<KeyPair>,
intermediate_params: Option<CertificateParams>,
intermediate_key: Option<KeyPair>,
} }
impl std::fmt::Debug for CaManager { impl std::fmt::Debug for CaManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CaManager") f.debug_struct("CaManager")
.field("config", &self.config) .field("config", &self.config)
.field("ca_cert", &self.ca_cert.is_some()) .field("ca_params", &self.ca_params.is_some())
.field("intermediate_cert", &self.intermediate_cert.is_some()) .field("intermediate_params", &self.intermediate_params.is_some())
.finish() .finish()
} }
} }
@ -107,8 +108,10 @@ impl CaManager {
pub fn new(config: CaConfig) -> Result<Self> { pub fn new(config: CaConfig) -> Result<Self> {
let mut manager = Self { let mut manager = Self {
config, config,
ca_cert: None, ca_params: None,
intermediate_cert: None, ca_key: None,
intermediate_params: None,
intermediate_key: None,
}; };
// Load existing CA if available // Load existing CA if available
@ -125,16 +128,13 @@ impl CaManager {
self.create_ca_directories()?; self.create_ca_directories()?;
// Generate root CA // Generate root CA
let ca_cert = self.generate_root_ca()?; self.generate_root_ca()?;
// Generate intermediate CA if configured // Generate intermediate CA if configured
if self.config.intermediate_cert_path.is_some() { if self.config.intermediate_cert_path.is_some() {
let intermediate = self.generate_intermediate_ca(&ca_cert)?; self.generate_intermediate_ca()?;
self.intermediate_cert = Some(intermediate);
} }
self.ca_cert = Some(ca_cert);
info!("Certificate Authority initialized successfully"); info!("Certificate Authority initialized successfully");
Ok(()) Ok(())
} }
@ -144,17 +144,21 @@ impl CaManager {
if self.config.ca_cert_path.exists() && self.config.ca_key_path.exists() { if self.config.ca_cert_path.exists() && self.config.ca_key_path.exists() {
debug!("Loading existing CA from {:?}", self.config.ca_cert_path); debug!("Loading existing CA from {:?}", self.config.ca_cert_path);
let _cert_pem = fs::read_to_string(&self.config.ca_cert_path)?;
let key_pem = fs::read_to_string(&self.config.ca_key_path)?; let key_pem = fs::read_to_string(&self.config.ca_key_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?; let key_pair = KeyPair::from_pem(&key_pem)?;
// Create CA params from scratch since rcgen doesn't support loading from PEM // Create CA params
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_pair = Some(key_pair);
self.ca_cert = Some(RcgenCertificate::from_params(params)?); let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Root CA");
params.distinguished_name = dn;
self.ca_params = Some(params);
self.ca_key = Some(key_pair);
// Load intermediate CA if exists // Load intermediate CA if exists
if let (Some(cert_path), Some(key_path)) = ( if let (Some(cert_path), Some(key_path)) = (
@ -162,17 +166,21 @@ impl CaManager {
&self.config.intermediate_key_path, &self.config.intermediate_key_path,
) { ) {
if cert_path.exists() && key_path.exists() { if cert_path.exists() && key_path.exists() {
let _cert_pem = fs::read_to_string(cert_path)?;
let key_pem = fs::read_to_string(key_path)?; let key_pem = fs::read_to_string(key_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?; let key_pair = KeyPair::from_pem(&key_pem)?;
// Create intermediate CA params // Create intermediate CA params
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_pair = Some(key_pair);
self.intermediate_cert = Some(RcgenCertificate::from_params(params)?); let mut dn = DistinguishedName::new();
dn.push(DnType::CountryName, &self.config.country);
dn.push(DnType::OrganizationName, &self.config.organization);
dn.push(DnType::CommonName, "BotServer Intermediate CA");
params.distinguished_name = dn;
self.intermediate_params = Some(params);
self.intermediate_key = Some(key_pair);
} }
} }
@ -185,7 +193,7 @@ impl CaManager {
} }
/// Generate root CA certificate /// Generate root CA certificate
fn generate_root_ca(&self) -> Result<RcgenCertificate> { fn generate_root_ca(&mut self) -> Result<()> {
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
// Set as CA certificate // Set as CA certificate
@ -206,22 +214,33 @@ impl CaManager {
OffsetDateTime::now_utc() + Duration::days(self.config.validity_days * 2); OffsetDateTime::now_utc() + Duration::days(self.config.validity_days * 2);
// Generate key pair // Generate key pair
let key_pair = KeyPair::generate(&rcgen::PKCS_RSA_SHA256)?; let key_pair = KeyPair::generate()?;
params.key_pair = Some(key_pair);
// Create certificate // Create self-signed certificate
let cert = RcgenCertificate::from_params(params)?; let cert = params.self_signed(&key_pair)?;
// Save to disk // Save to disk
fs::write(&self.config.ca_cert_path, cert.serialize_pem()?)?; fs::write(&self.config.ca_cert_path, cert.pem())?;
fs::write(&self.config.ca_key_path, cert.serialize_private_key_pem())?; fs::write(&self.config.ca_key_path, key_pair.serialize_pem())?;
self.ca_params = Some(params);
self.ca_key = Some(key_pair);
info!("Generated root CA certificate"); info!("Generated root CA certificate");
Ok(cert) Ok(())
} }
/// Generate intermediate CA certificate /// Generate intermediate CA certificate
fn generate_intermediate_ca(&self, root_ca: &RcgenCertificate) -> Result<RcgenCertificate> { fn generate_intermediate_ca(&mut self) -> Result<()> {
let ca_params = self
.ca_params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Root CA params not available"))?;
let ca_key = self
.ca_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Root CA key not available"))?;
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
// Set as intermediate CA // Set as intermediate CA
@ -241,26 +260,28 @@ impl CaManager {
params.not_after = OffsetDateTime::now_utc() + Duration::days(self.config.validity_days); params.not_after = OffsetDateTime::now_utc() + Duration::days(self.config.validity_days);
// Generate key pair // Generate key pair
let key_pair = KeyPair::generate(&rcgen::PKCS_RSA_SHA256)?; let key_pair = KeyPair::generate()?;
params.key_pair = Some(key_pair);
// Create certificate // Create issuer from root CA
let cert = RcgenCertificate::from_params(params)?; let issuer = Issuer::from_params(ca_params, ca_key);
// Sign with root CA // Create certificate signed by root CA
let signed_cert = cert.serialize_pem_with_signer(root_ca)?; let cert = params.signed_by(&key_pair, &issuer)?;
// Save to disk // Save to disk
if let (Some(cert_path), Some(key_path)) = ( if let (Some(cert_path), Some(key_path)) = (
&self.config.intermediate_cert_path, &self.config.intermediate_cert_path,
&self.config.intermediate_key_path, &self.config.intermediate_key_path,
) { ) {
fs::write(cert_path, signed_cert)?; fs::write(cert_path, cert.pem())?;
fs::write(key_path, cert.serialize_private_key_pem())?; fs::write(key_path, key_pair.serialize_pem())?;
} }
self.intermediate_params = Some(params);
self.intermediate_key = Some(key_pair);
info!("Generated intermediate CA certificate"); info!("Generated intermediate CA certificate");
Ok(cert) Ok(())
} }
/// Issue a new certificate for a service /// Issue a new certificate for a service
@ -270,11 +291,14 @@ impl CaManager {
san_names: Vec<String>, san_names: Vec<String>,
is_client: bool, is_client: bool,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let signing_ca = self let (signing_params, signing_key) =
.intermediate_cert match (&self.intermediate_params, &self.intermediate_key) {
.as_ref() (Some(params), Some(key)) => (params, key),
.or(self.ca_cert.as_ref()) _ => match (&self.ca_params, &self.ca_key) {
.ok_or_else(|| anyhow::anyhow!("CA not initialized"))?; (Some(params), Some(key)) => (params, key),
_ => return Err(anyhow::anyhow!("CA not initialized")),
},
};
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
@ -294,7 +318,9 @@ impl CaManager {
.subject_alt_names .subject_alt_names
.push(SanType::IpAddress(san.parse()?)); .push(SanType::IpAddress(san.parse()?));
} else { } else {
params.subject_alt_names.push(SanType::DnsName(san)); params
.subject_alt_names
.push(SanType::DnsName(san.try_into()?));
} }
} }
@ -310,13 +336,15 @@ impl CaManager {
} }
// Generate key pair // Generate key pair
let key_pair = KeyPair::generate(&rcgen::PKCS_RSA_SHA256)?; let key_pair = KeyPair::generate()?;
params.key_pair = Some(key_pair);
// Create issuer from signing CA
let issuer = Issuer::from_params(signing_params, signing_key);
// Create and sign certificate // Create and sign certificate
let cert = RcgenCertificate::from_params(params)?; let cert = params.signed_by(&key_pair, &issuer)?;
let cert_pem = cert.serialize_pem_with_signer(signing_ca)?; let cert_pem = cert.pem();
let key_pem = cert.serialize_private_key_pem(); let key_pem = key_pair.serialize_pem();
Ok((cert_pem, key_pem)) Ok((cert_pem, key_pem))
} }

View file

@ -3,14 +3,39 @@
//! Implements hybrid search combining sparse (BM25) and dense (embedding) retrieval //! Implements hybrid search combining sparse (BM25) and dense (embedding) retrieval
//! with Reciprocal Rank Fusion (RRF) for optimal results. //! with Reciprocal Rank Fusion (RRF) for optimal results.
//! //!
//! Config.csv properties: //! # Features
//!
//! - **BM25 Sparse Search**: Powered by Tantivy (when `vectordb` feature enabled)
//! - **Dense Search**: Uses Qdrant for embedding-based similarity search
//! - **Hybrid Fusion**: Reciprocal Rank Fusion (RRF) combines both methods
//! - **Reranking**: Optional cross-encoder reranking for improved relevance
//!
//! # Config.csv Properties
//!
//! ```csv //! ```csv
//! # Hybrid search weights
//! rag-hybrid-enabled,true //! rag-hybrid-enabled,true
//! rag-dense-weight,0.7 //! rag-dense-weight,0.7
//! rag-sparse-weight,0.3 //! rag-sparse-weight,0.3
//! rag-reranker-enabled,true //! rag-reranker-enabled,true
//! rag-reranker-model,cross-encoder/ms-marco-MiniLM-L-6-v2 //! rag-reranker-model,cross-encoder/ms-marco-MiniLM-L-6-v2
//! rag-max-results,10
//! rag-min-score,0.0
//! rag-rrf-k,60
//!
//! # BM25 tuning (see bm25_config.rs for details)
//! bm25-enabled,true
//! bm25-k1,1.2
//! bm25-b,0.75
//! bm25-stemming,true
//! bm25-stopwords,true
//! ``` //! ```
//!
//! # Switching Search Modes
//!
//! - **Hybrid (default)**: Set `rag-hybrid-enabled=true` and `bm25-enabled=true`
//! - **Dense only**: Set `bm25-enabled=false` (faster, semantic search only)
//! - **Sparse only**: Set `rag-dense-weight=0` and `rag-sparse-weight=1`
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -37,6 +62,8 @@ pub struct HybridSearchConfig {
pub min_score: f32, pub min_score: f32,
/// K parameter for RRF (typically 60) /// K parameter for RRF (typically 60)
pub rrf_k: u32, pub rrf_k: u32,
/// Whether BM25 sparse search is enabled
pub bm25_enabled: bool,
} }
impl Default for HybridSearchConfig { impl Default for HybridSearchConfig {
@ -49,6 +76,7 @@ impl Default for HybridSearchConfig {
max_results: 10, max_results: 10,
min_score: 0.0, min_score: 0.0,
rrf_k: 60, rrf_k: 60,
bm25_enabled: true,
} }
} }
} }
@ -71,7 +99,7 @@ impl HybridSearchConfig {
let configs: Vec<ConfigRow> = diesel::sql_query( let configs: Vec<ConfigRow> = diesel::sql_query(
"SELECT config_key, config_value FROM bot_configuration \ "SELECT config_key, config_value FROM bot_configuration \
WHERE bot_id = $1 AND config_key LIKE 'rag-%'", WHERE bot_id = $1 AND (config_key LIKE 'rag-%' OR config_key LIKE 'bm25-%')",
) )
.bind::<diesel::sql_types::Uuid, _>(bot_id) .bind::<diesel::sql_types::Uuid, _>(bot_id)
.load(&mut conn) .load(&mut conn)
@ -100,6 +128,9 @@ impl HybridSearchConfig {
"rag-rrf-k" => { "rag-rrf-k" => {
config.rrf_k = row.config_value.parse().unwrap_or(60); config.rrf_k = row.config_value.parse().unwrap_or(60);
} }
"bm25-enabled" => {
config.bm25_enabled = row.config_value.to_lowercase() == "true";
}
_ => {} _ => {}
} }
} }
@ -112,8 +143,23 @@ impl HybridSearchConfig {
config.sparse_weight /= total; config.sparse_weight /= total;
} }
debug!(
"Loaded HybridSearchConfig: dense={}, sparse={}, bm25_enabled={}",
config.dense_weight, config.sparse_weight, config.bm25_enabled
);
config config
} }
/// Check if sparse (BM25) search should be used
pub fn use_sparse_search(&self) -> bool {
self.bm25_enabled && self.sparse_weight > 0.0
}
/// Check if dense (embedding) search should be used
pub fn use_dense_search(&self) -> bool {
self.dense_weight > 0.0
}
} }
/// Search result from any retrieval method /// Search result from any retrieval method
@ -142,23 +188,23 @@ pub enum SearchMethod {
Reranked, Reranked,
} }
/// BM25 search index for sparse retrieval // ============================================================================
// Built-in BM25 Index Implementation
// ============================================================================
pub struct BM25Index { pub struct BM25Index {
/// Document frequency for each term
doc_freq: HashMap<String, usize>, doc_freq: HashMap<String, usize>,
/// Total number of documents
doc_count: usize, doc_count: usize,
/// Average document length
avg_doc_len: f32, avg_doc_len: f32,
/// Document lengths
doc_lengths: HashMap<String, usize>, doc_lengths: HashMap<String, usize>,
/// Term frequencies per document
term_freqs: HashMap<String, HashMap<String, usize>>, term_freqs: HashMap<String, HashMap<String, usize>>,
/// BM25 parameters doc_sources: HashMap<String, String>,
k1: f32, k1: f32,
b: f32, b: f32,
enabled: bool,
} }
#[cfg(not(feature = "vectordb"))]
impl BM25Index { impl BM25Index {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -167,27 +213,31 @@ impl BM25Index {
avg_doc_len: 0.0, avg_doc_len: 0.0,
doc_lengths: HashMap::new(), doc_lengths: HashMap::new(),
term_freqs: HashMap::new(), term_freqs: HashMap::new(),
doc_sources: HashMap::new(),
k1: 1.2, k1: 1.2,
b: 0.75, b: 0.75,
enabled: true,
} }
} }
/// Add a document to the index pub fn add_document(&mut self, doc_id: &str, content: &str, source: &str) {
pub fn add_document(&mut self, doc_id: &str, content: &str) { if !self.enabled {
return;
}
let terms = self.tokenize(content); let terms = self.tokenize(content);
let doc_len = terms.len(); let doc_len = terms.len();
// Update document length
self.doc_lengths.insert(doc_id.to_string(), doc_len); self.doc_lengths.insert(doc_id.to_string(), doc_len);
self.doc_sources
.insert(doc_id.to_string(), source.to_string());
// Calculate term frequencies
let mut term_freq: HashMap<String, usize> = HashMap::new(); let mut term_freq: HashMap<String, usize> = HashMap::new();
let mut seen_terms: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen_terms: std::collections::HashSet<String> = std::collections::HashSet::new();
for term in &terms { for term in &terms {
*term_freq.entry(term.clone()).or_insert(0) += 1; *term_freq.entry(term.clone()).or_insert(0) += 1;
// Update document frequency (only once per document per term)
if !seen_terms.contains(term) { if !seen_terms.contains(term) {
*self.doc_freq.entry(term.clone()).or_insert(0) += 1; *self.doc_freq.entry(term.clone()).or_insert(0) += 1;
seen_terms.insert(term.clone()); seen_terms.insert(term.clone());
@ -197,15 +247,12 @@ impl BM25Index {
self.term_freqs.insert(doc_id.to_string(), term_freq); self.term_freqs.insert(doc_id.to_string(), term_freq);
self.doc_count += 1; self.doc_count += 1;
// Update average document length
let total_len: usize = self.doc_lengths.values().sum(); let total_len: usize = self.doc_lengths.values().sum();
self.avg_doc_len = total_len as f32 / self.doc_count as f32; self.avg_doc_len = total_len as f32 / self.doc_count as f32;
} }
/// Remove a document from the index
pub fn remove_document(&mut self, doc_id: &str) { pub fn remove_document(&mut self, doc_id: &str) {
if let Some(term_freq) = self.term_freqs.remove(doc_id) { if let Some(term_freq) = self.term_freqs.remove(doc_id) {
// Update document frequencies
for term in term_freq.keys() { for term in term_freq.keys() {
if let Some(freq) = self.doc_freq.get_mut(term) { if let Some(freq) = self.doc_freq.get_mut(term) {
*freq = freq.saturating_sub(1); *freq = freq.saturating_sub(1);
@ -217,9 +264,9 @@ impl BM25Index {
} }
self.doc_lengths.remove(doc_id); self.doc_lengths.remove(doc_id);
self.doc_sources.remove(doc_id);
self.doc_count = self.doc_count.saturating_sub(1); self.doc_count = self.doc_count.saturating_sub(1);
// Update average document length
if self.doc_count > 0 { if self.doc_count > 0 {
let total_len: usize = self.doc_lengths.values().sum(); let total_len: usize = self.doc_lengths.values().sum();
self.avg_doc_len = total_len as f32 / self.doc_count as f32; self.avg_doc_len = total_len as f32 / self.doc_count as f32;
@ -228,8 +275,11 @@ impl BM25Index {
} }
} }
/// Search the index with BM25 scoring pub fn search(&self, query: &str, max_results: usize) -> Vec<(String, String, f32)> {
pub fn search(&self, query: &str, max_results: usize) -> Vec<(String, f32)> { if !self.enabled {
return Vec::new();
}
let query_terms = self.tokenize(query); let query_terms = self.tokenize(query);
let mut scores: HashMap<String, f32> = HashMap::new(); let mut scores: HashMap<String, f32> = HashMap::new();
@ -239,7 +289,6 @@ impl BM25Index {
continue; continue;
} }
// IDF calculation
let idf = ((self.doc_count as f32 - df as f32 + 0.5) / (df as f32 + 0.5) + 1.0).ln(); let idf = ((self.doc_count as f32 - df as f32 + 0.5) / (df as f32 + 0.5) + 1.0).ln();
for (doc_id, term_freqs) in &self.term_freqs { for (doc_id, term_freqs) in &self.term_freqs {
@ -254,31 +303,39 @@ impl BM25Index {
} }
} }
// Sort by score and return top results
let mut results: Vec<(String, f32)> = scores.into_iter().collect(); let mut results: Vec<(String, f32)> = scores.into_iter().collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(max_results); results.truncate(max_results);
results results
.into_iter()
.map(|(doc_id, score)| {
let source = self.doc_sources.get(&doc_id).cloned().unwrap_or_default();
(doc_id, source, score)
})
.collect()
} }
/// Tokenize text into terms
fn tokenize(&self, text: &str) -> Vec<String> { fn tokenize(&self, text: &str) -> Vec<String> {
text.to_lowercase() text.to_lowercase()
.split(|c: char| !c.is_alphanumeric()) .split(|c: char| !c.is_alphanumeric())
.filter(|s| s.len() > 2) // Filter out very short tokens .filter(|s| s.len() > 2)
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect() .collect()
} }
/// Get index statistics
pub fn stats(&self) -> BM25Stats { pub fn stats(&self) -> BM25Stats {
BM25Stats { BM25Stats {
doc_count: self.doc_count, doc_count: self.doc_count,
unique_terms: self.doc_freq.len(), unique_terms: self.doc_freq.len(),
avg_doc_len: self.avg_doc_len, avg_doc_len: self.avg_doc_len,
enabled: self.enabled,
} }
} }
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
} }
impl Default for BM25Index { impl Default for BM25Index {
@ -288,16 +345,29 @@ impl Default for BM25Index {
} }
/// BM25 index statistics /// BM25 index statistics
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BM25Stats { pub struct BM25Stats {
pub doc_count: usize, pub doc_count: usize,
pub unique_terms: usize, pub unique_terms: usize,
pub avg_doc_len: f32, pub avg_doc_len: f32,
pub enabled: bool,
}
// ============================================================================
// Hybrid Search Engine
// ============================================================================
/// Document entry in the store
#[derive(Debug, Clone)]
struct DocumentEntry {
pub content: String,
pub source: String,
pub metadata: HashMap<String, String>,
} }
/// Hybrid search engine combining dense and sparse retrieval /// Hybrid search engine combining dense and sparse retrieval
pub struct HybridSearchEngine { pub struct HybridSearchEngine {
/// BM25 sparse index /// BM25 sparse index (built-in implementation)
bm25_index: BM25Index, bm25_index: BM25Index,
/// Document store for content retrieval /// Document store for content retrieval
documents: HashMap<String, DocumentEntry>, documents: HashMap<String, DocumentEntry>,
@ -309,18 +379,18 @@ pub struct HybridSearchEngine {
collection_name: String, collection_name: String,
} }
/// Document entry in the store
#[derive(Debug, Clone)]
struct DocumentEntry {
pub content: String,
pub source: String,
pub metadata: HashMap<String, String>,
}
impl HybridSearchEngine { impl HybridSearchEngine {
pub fn new(config: HybridSearchConfig, qdrant_url: &str, collection_name: &str) -> Self { pub fn new(config: HybridSearchConfig, qdrant_url: &str, collection_name: &str) -> Self {
let mut bm25_index = BM25Index::new();
bm25_index.set_enabled(config.bm25_enabled);
info!(
"Created HybridSearchEngine with fallback BM25 (enabled={})",
config.bm25_enabled
);
Self { Self {
bm25_index: BM25Index::new(), bm25_index,
documents: HashMap::new(), documents: HashMap::new(),
config, config,
qdrant_url: qdrant_url.to_string(), qdrant_url: qdrant_url.to_string(),
@ -337,8 +407,8 @@ impl HybridSearchEngine {
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
embedding: Option<Vec<f32>>, embedding: Option<Vec<f32>>,
) -> Result<(), String> { ) -> Result<(), String> {
// Add to BM25 index // Add to BM25 index (fallback)
self.bm25_index.add_document(doc_id, content); self.bm25_index.add_document(doc_id, content, source);
// Store document // Store document
self.documents.insert( self.documents.insert(
@ -358,6 +428,11 @@ impl HybridSearchEngine {
Ok(()) Ok(())
} }
/// Commit pending BM25 index changes
pub fn commit(&mut self) -> Result<(), String> {
Ok(())
}
/// Remove a document from all indexes /// Remove a document from all indexes
pub async fn remove_document(&mut self, doc_id: &str) -> Result<(), String> { pub async fn remove_document(&mut self, doc_id: &str) -> Result<(), String> {
self.bm25_index.remove_document(doc_id); self.bm25_index.remove_document(doc_id);
@ -372,34 +447,46 @@ impl HybridSearchEngine {
query: &str, query: &str,
query_embedding: Option<Vec<f32>>, query_embedding: Option<Vec<f32>>,
) -> Result<Vec<SearchResult>, String> { ) -> Result<Vec<SearchResult>, String> {
let fetch_count = self.config.max_results * 3; // Fetch more for fusion let fetch_count = self.config.max_results * 3;
// Sparse search (BM25) // Sparse search (BM25 fallback)
let sparse_results = self.bm25_index.search(query, fetch_count); let sparse_results: Vec<(String, f32)> = if self.config.use_sparse_search() {
trace!( self.bm25_index
"BM25 search returned {} results for query: {}", .search(query, fetch_count)
sparse_results.len(), .into_iter()
query .map(|(doc_id, _source, score)| (doc_id, score))
); .collect()
// Dense search (Qdrant)
let dense_results = if let Some(embedding) = query_embedding {
self.search_qdrant(&embedding, fetch_count).await?
} else { } else {
Vec::new() Vec::new()
}; };
trace!(
"Dense search returned {} results for query: {}",
dense_results.len(),
query
);
// Reciprocal Rank Fusion // Dense search (Qdrant)
let fused_results = self.reciprocal_rank_fusion(&sparse_results, &dense_results); let dense_results = if self.config.use_dense_search() {
trace!("RRF produced {} fused results", fused_results.len()); if let Some(embedding) = query_embedding {
self.search_qdrant(&embedding, fetch_count).await?
} else {
Vec::new()
}
} else {
Vec::new()
};
// Combine results
let (results, method) = if sparse_results.is_empty() && dense_results.is_empty() {
(Vec::new(), SearchMethod::Hybrid)
} else if sparse_results.is_empty() {
(dense_results.clone(), SearchMethod::Dense)
} else if dense_results.is_empty() {
(sparse_results.clone(), SearchMethod::Sparse)
} else {
(
self.reciprocal_rank_fusion(&sparse_results, &dense_results),
SearchMethod::Hybrid,
)
};
// Convert to SearchResult // Convert to SearchResult
let mut results: Vec<SearchResult> = fused_results let mut search_results: Vec<SearchResult> = results
.into_iter() .into_iter()
.filter_map(|(doc_id, score)| { .filter_map(|(doc_id, score)| {
self.documents.get(&doc_id).map(|doc| SearchResult { self.documents.get(&doc_id).map(|doc| SearchResult {
@ -408,7 +495,7 @@ impl HybridSearchEngine {
source: doc.source.clone(), source: doc.source.clone(),
score, score,
metadata: doc.metadata.clone(), metadata: doc.metadata.clone(),
search_method: SearchMethod::Hybrid, search_method: method.clone(),
}) })
}) })
.filter(|r| r.score >= self.config.min_score) .filter(|r| r.score >= self.config.min_score)
@ -416,11 +503,11 @@ impl HybridSearchEngine {
.collect(); .collect();
// Optional reranking // Optional reranking
if self.config.reranker_enabled && !results.is_empty() { if self.config.reranker_enabled && !search_results.is_empty() {
results = self.rerank(query, results).await?; search_results = self.rerank(query, search_results).await?;
} }
Ok(results) Ok(search_results)
} }
/// Perform only sparse (BM25) search /// Perform only sparse (BM25) search
@ -429,7 +516,7 @@ impl HybridSearchEngine {
results results
.into_iter() .into_iter()
.filter_map(|(doc_id, score)| { .filter_map(|(doc_id, _source, score)| {
self.documents.get(&doc_id).map(|doc| SearchResult { self.documents.get(&doc_id).map(|doc| SearchResult {
doc_id, doc_id,
content: doc.content.clone(), content: doc.content.clone(),
@ -511,12 +598,11 @@ impl HybridSearchEngine {
query: &str, query: &str,
results: Vec<SearchResult>, results: Vec<SearchResult>,
) -> Result<Vec<SearchResult>, String> { ) -> Result<Vec<SearchResult>, String> {
// In a full implementation, this would call a cross-encoder model // Simple reranking based on query term overlap
// For now, we'll use a simple relevance heuristic // A full implementation would call a cross-encoder model API
let mut reranked = results; let mut reranked = results;
for result in &mut reranked { for result in &mut reranked {
// Simple reranking based on query term overlap
let query_terms: std::collections::HashSet<&str> = let query_terms: std::collections::HashSet<&str> =
query.to_lowercase().split_whitespace().collect(); query.to_lowercase().split_whitespace().collect();
let content_lower = result.content.to_lowercase(); let content_lower = result.content.to_lowercase();
@ -528,14 +614,16 @@ impl HybridSearchEngine {
} }
} }
// Combine original score with overlap
let overlap_normalized = overlap_score / query_terms.len().max(1) as f32; let overlap_normalized = overlap_score / query_terms.len().max(1) as f32;
result.score = result.score * 0.7 + overlap_normalized * 0.3; result.score = result.score * 0.7 + overlap_normalized * 0.3;
result.search_method = SearchMethod::Reranked; result.search_method = SearchMethod::Reranked;
} }
// Re-sort by new scores reranked.sort_by(|a, b| {
reranked.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(reranked) Ok(reranked)
} }
@ -657,6 +745,7 @@ impl HybridSearchEngine {
bm25_doc_count: bm25_stats.doc_count, bm25_doc_count: bm25_stats.doc_count,
unique_terms: bm25_stats.unique_terms, unique_terms: bm25_stats.unique_terms,
avg_doc_len: bm25_stats.avg_doc_len, avg_doc_len: bm25_stats.avg_doc_len,
bm25_enabled: bm25_stats.enabled,
config: self.config.clone(), config: self.config.clone(),
} }
} }
@ -669,9 +758,14 @@ pub struct HybridSearchStats {
pub bm25_doc_count: usize, pub bm25_doc_count: usize,
pub unique_terms: usize, pub unique_terms: usize,
pub avg_doc_len: f32, pub avg_doc_len: f32,
pub bm25_enabled: bool,
pub config: HybridSearchConfig, pub config: HybridSearchConfig,
} }
// ============================================================================
// Query Decomposition
// ============================================================================
/// Query decomposition for complex questions /// Query decomposition for complex questions
pub struct QueryDecomposer { pub struct QueryDecomposer {
llm_endpoint: String, llm_endpoint: String,
@ -688,9 +782,6 @@ impl QueryDecomposer {
/// Decompose a complex query into simpler sub-queries /// Decompose a complex query into simpler sub-queries
pub async fn decompose(&self, query: &str) -> Result<Vec<String>, String> { pub async fn decompose(&self, query: &str) -> Result<Vec<String>, String> {
// Simple heuristic decomposition for common patterns
// A full implementation would use an LLM
let mut sub_queries = Vec::new(); let mut sub_queries = Vec::new();
// Check for conjunctions // Check for conjunctions
@ -711,7 +802,6 @@ impl QueryDecomposer {
sub_queries.push(part.to_string()); sub_queries.push(part.to_string());
} }
} else { } else {
// Try question word splitting
let question_words = ["what", "how", "why", "when", "where", "who"]; let question_words = ["what", "how", "why", "when", "where", "who"];
let lower = query.to_lowercase(); let lower = query.to_lowercase();
@ -724,7 +814,6 @@ impl QueryDecomposer {
} }
if has_multiple_questions { if has_multiple_questions {
// Split on question marks or question words
for part in query.split('?') { for part in query.split('?') {
let trimmed = part.trim(); let trimmed = part.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
@ -734,7 +823,6 @@ impl QueryDecomposer {
} }
} }
// If no decomposition happened, return original query
if sub_queries.is_empty() { if sub_queries.is_empty() {
sub_queries.push(query.to_string()); sub_queries.push(query.to_string());
} }
@ -748,8 +836,10 @@ impl QueryDecomposer {
return sub_answers[0].clone(); return sub_answers[0].clone();
} }
// Simple concatenation with context let mut synthesis = format!(
let mut synthesis = format!("Based on your question about \"{}\", here's what I found:\n\n", query); "Based on your question about \"{}\", here's what I found:\n\n",
query
);
for (i, answer) in sub_answers.iter().enumerate() { for (i, answer) in sub_answers.iter().enumerate() {
synthesis.push_str(&format!("{}. {}\n\n", i + 1, answer)); synthesis.push_str(&format!("{}. {}\n\n", i + 1, answer));
@ -759,54 +849,14 @@ impl QueryDecomposer {
} }
} }
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_bm25_index_basic() {
let mut index = BM25Index::new();
index.add_document("doc1", "The quick brown fox jumps over the lazy dog");
index.add_document("doc2", "A quick brown dog runs in the park");
index.add_document("doc3", "The lazy cat sleeps all day");
let stats = index.stats();
assert_eq!(stats.doc_count, 3);
assert!(stats.avg_doc_len > 0.0);
}
#[test]
fn test_bm25_search() {
let mut index = BM25Index::new();
index.add_document("doc1", "machine learning artificial intelligence");
index.add_document("doc2", "natural language processing NLP");
index.add_document("doc3", "computer vision image recognition");
let results = index.search("machine learning", 10);
assert!(!results.is_empty());
assert_eq!(results[0].0, "doc1"); // doc1 should be first
}
#[test]
fn test_bm25_remove_document() {
let mut index = BM25Index::new();
index.add_document("doc1", "test document one");
index.add_document("doc2", "test document two");
assert_eq!(index.stats().doc_count, 2);
index.remove_document("doc1");
assert_eq!(index.stats().doc_count, 1);
let results = index.search("one", 10);
assert!(results.is_empty() || results[0].0 != "doc1");
}
#[test] #[test]
fn test_hybrid_config_default() { fn test_hybrid_config_default() {
let config = HybridSearchConfig::default(); let config = HybridSearchConfig::default();
@ -815,6 +865,29 @@ mod tests {
assert_eq!(config.sparse_weight, 0.3); assert_eq!(config.sparse_weight, 0.3);
assert!(!config.reranker_enabled); assert!(!config.reranker_enabled);
assert_eq!(config.max_results, 10); assert_eq!(config.max_results, 10);
assert!(config.bm25_enabled);
}
#[test]
fn test_hybrid_config_search_modes() {
let config = HybridSearchConfig::default();
assert!(config.use_sparse_search());
assert!(config.use_dense_search());
let dense_only = HybridSearchConfig {
bm25_enabled: false,
..Default::default()
};
assert!(!dense_only.use_sparse_search());
assert!(dense_only.use_dense_search());
let sparse_only = HybridSearchConfig {
dense_weight: 0.0,
sparse_weight: 1.0,
..Default::default()
};
assert!(sparse_only.use_sparse_search());
assert!(!sparse_only.use_dense_search());
} }
#[test] #[test]
@ -836,8 +909,8 @@ mod tests {
let fused = engine.reciprocal_rank_fusion(&sparse, &dense); let fused = engine.reciprocal_rank_fusion(&sparse, &dense);
// doc1 and doc2 should be in top results as they appear in both
assert!(!fused.is_empty()); assert!(!fused.is_empty());
// doc1 and doc2 appear in both, should rank high
let top_ids: Vec<&str> = fused.iter().take(2).map(|(id, _)| id.as_str()).collect(); let top_ids: Vec<&str> = fused.iter().take(2).map(|(id, _)| id.as_str()).collect();
assert!(top_ids.contains(&"doc1") || top_ids.contains(&"doc2")); assert!(top_ids.contains(&"doc1") || top_ids.contains(&"doc2"));
} }
@ -846,7 +919,6 @@ mod tests {
fn test_query_decomposer_simple() { fn test_query_decomposer_simple() {
let decomposer = QueryDecomposer::new("http://localhost:8081", "none"); let decomposer = QueryDecomposer::new("http://localhost:8081", "none");
// Use tokio runtime for async test
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async { let result = rt.block_on(async {
@ -878,4 +950,40 @@ mod tests {
assert!(parsed.is_ok()); assert!(parsed.is_ok());
assert_eq!(parsed.unwrap().doc_id, "test123"); assert_eq!(parsed.unwrap().doc_id, "test123");
} }
#[cfg(not(feature = "vectordb"))]
#[test]
fn test_fallback_bm25_index() {
let mut index = BM25Index::new();
index.add_document(
"doc1",
"machine learning artificial intelligence",
"source1",
);
index.add_document("doc2", "natural language processing NLP", "source2");
index.add_document("doc3", "computer vision image recognition", "source3");
let results = index.search("machine learning", 10);
assert!(!results.is_empty());
assert_eq!(results[0].0, "doc1");
let stats = index.stats();
assert_eq!(stats.doc_count, 3);
assert!(stats.enabled);
}
#[cfg(not(feature = "vectordb"))]
#[test]
fn test_fallback_bm25_disabled() {
let mut index = BM25Index::new();
index.set_enabled(false);
index.add_document("doc1", "test content", "source1");
let results = index.search("test", 10);
assert!(results.is_empty());
assert!(!index.stats().enabled);
}
} }

View file

@ -1,8 +1,52 @@
//! Vector Database Module for RAG 2.0
//!
//! This module provides hybrid search capabilities combining:
//! - **Sparse Search (BM25)**: Powered by Tantivy when `vectordb` feature is enabled
//! - **Dense Search**: Uses Qdrant for embedding-based similarity search
//! - **Hybrid Fusion**: Reciprocal Rank Fusion (RRF) combines both methods
//!
//! # Features
//!
//! Enable the `vectordb` feature in Cargo.toml to use Tantivy-based BM25:
//! ```toml
//! [features]
//! vectordb = ["dep:qdrant-client", "dep:tantivy"]
//! ```
//!
//! # Configuration
//!
//! Configure via config.csv:
//! ```csv
//! # Enable/disable BM25 sparse search
//! bm25-enabled,true
//! bm25-k1,1.2
//! bm25-b,0.75
//!
//! # Hybrid search weights
//! rag-dense-weight,0.7
//! rag-sparse-weight,0.3
//! ```
pub mod bm25_config;
pub mod hybrid_search; pub mod hybrid_search;
pub mod vectordb_indexer; pub mod vectordb_indexer;
// BM25 Configuration exports
pub use bm25_config::{is_stopword, Bm25Config, DEFAULT_STOPWORDS};
// Hybrid Search exports
pub use hybrid_search::{ pub use hybrid_search::{
BM25Index, BM25Stats, HybridSearchConfig, HybridSearchEngine, HybridSearchStats, BM25Stats, HybridSearchConfig, HybridSearchEngine, HybridSearchStats, QueryDecomposer,
QueryDecomposer, SearchMethod, SearchResult, SearchMethod, SearchResult,
}; };
// Tantivy BM25 index (when vectordb feature enabled)
#[cfg(feature = "vectordb")]
pub use hybrid_search::TantivyBM25Index;
// Fallback BM25 index (when vectordb feature NOT enabled)
#[cfg(not(feature = "vectordb"))]
pub use hybrid_search::BM25Index;
// VectorDB Indexer exports
pub use vectordb_indexer::{IndexingStats, IndexingStatus, VectorDBIndexer}; pub use vectordb_indexer::{IndexingStats, IndexingStatus, VectorDBIndexer};

View file

@ -1,12 +1,398 @@
How to test (using api.pragmatismo.com.br as host): # LLM Server Template (llm-server.gbai)
POST https://api.pragmatismo.com.br/llmservergbot/dialogs/start A General Bots template for deploying LLM-powered web services that process orders and requests via API endpoints.
## Overview
The LLM Server template transforms General Bots into a headless API service that processes structured requests using LLM intelligence. It's designed for integrating AI-powered order processing, chatbot backends, and automated response systems into existing applications.
## Features
- **REST API Endpoints** - HTTP endpoints for bot interaction
- **Order Processing** - Structured JSON responses for orders
- **Product Catalog Integration** - Dynamic product menu from CSV
- **System Prompt Configuration** - Customizable AI behavior
- **Session Management** - Track conversations across requests
- **Operator Support** - Multi-operator/tenant architecture
## Package Structure
```
llm-server.gbai/
├── README.md
├── llm-server.gbdata/ # Data files
│ └── products.csv # Product catalog
├── llm-server.gbdialog/
│ └── start.bas # Main dialog with system prompt
├── llm-server.gbkb/ # Knowledge base
└── llm-server.gbot/
└── config.csv # Bot configuration
```
## API Endpoints
### Start a Session
```http
POST https://{host}/{botId}/dialogs/start
Content-Type: application/x-www-form-urlencoded
operator=123 operator=123
userSystemId=999 userSystemId=999
```
**Response:**
```json
{
"pid": "1237189231897",
"conversationId": "abc123",
"status": "started"
}
```
POST https://api.pragmatismo.com.br/api/dk/messageBot ### Send a Message
pid=1237189231897 (returned) ```http
text=soda POST https://{host}/api/dk/messageBot
Content-Type: application/x-www-form-urlencoded
pid=1237189231897
text=I want a banana
```
**Response:**
```json
{
"orderedItems": [
{
"item": {
"id": 102,
"price": 0.30,
"name": "Banana",
"quantity": 1,
"notes": ""
}
}
],
"userId": "123",
"accountIdentifier": "TableA",
"deliveryTypeId": 2
}
```
## Configuration
### System Prompt
The `start.bas` defines the AI behavior:
```basic
PARAM operator AS number LIKE 12312312 DESCRIPTION "Operator code."
DESCRIPTION It is a WebService of GB.
products = FIND "products.csv"
BEGIN SYSTEM PROMPT
You are a chatbot assisting a store attendant in processing orders. Follow these rules:
1. **Order Format**: Each order must include the product name, the table number, and the customer's name.
2. **Product Details**: The available products are:
${TOYAML(products)}
3. **JSON Response**: For each order, return a valid RFC 8259 JSON object containing:
- product name
- table number
4. **Guidelines**:
- Do **not** engage in conversation.
- Return the response in plain text JSON format only.
END SYSTEM PROMPT
```
### Product Catalog
Create `products.csv` in the `llm-server.gbdata` folder:
```csv
id,name,price,category,description
101,Apple,0.50,Fruit,Fresh red apple
102,Banana,0.30,Fruit,Ripe yellow banana
103,Orange,0.40,Fruit,Juicy orange
201,Milk,1.20,Dairy,1 liter whole milk
202,Cheese,2.50,Dairy,200g cheddar
```
### Bot Configuration
Configure in `llm-server.gbot/config.csv`:
| Parameter | Description | Example |
|-----------|-------------|---------|
| `LLM Provider` | AI model provider | `openai` |
| `LLM Model` | Specific model | `gpt-4` |
| `Max Tokens` | Response length limit | `500` |
| `Temperature` | Response creativity | `0.3` |
| `API Mode` | Enable API mode | `true` |
## Usage Examples
### cURL Examples
**Start Session:**
```bash
curl -X POST https://api.example.com/llmservergbot/dialogs/start \
-d "operator=123" \
-d "userSystemId=999"
```
**Send Order:**
```bash
curl -X POST https://api.example.com/api/dk/messageBot \
-d "pid=1237189231897" \
-d "text=I need 2 apples and 1 milk"
```
### JavaScript Integration
```javascript
async function startBotSession(operator, userId) {
const response = await fetch('https://api.example.com/llmservergbot/dialogs/start', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ operator, userSystemId: userId })
});
return response.json();
}
async function sendMessage(pid, text) {
const response = await fetch('https://api.example.com/api/dk/messageBot', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ pid, text })
});
return response.json();
}
// Usage
const session = await startBotSession('123', '999');
const order = await sendMessage(session.pid, 'I want a banana');
console.log(order.orderedItems);
```
### Python Integration
```python
import requests
class LLMServerClient:
def __init__(self, base_url, operator):
self.base_url = base_url
self.operator = operator
self.pid = None
def start_session(self, user_id):
response = requests.post(
f"{self.base_url}/llmservergbot/dialogs/start",
data={"operator": self.operator, "userSystemId": user_id}
)
self.pid = response.json()["pid"]
return self.pid
def send_message(self, text):
response = requests.post(
f"{self.base_url}/api/dk/messageBot",
data={"pid": self.pid, "text": text}
)
return response.json()
# Usage
client = LLMServerClient("https://api.example.com", "123")
client.start_session("999")
order = client.send_message("I need 2 bananas")
print(order)
```
## Response Format
### Order Response Structure
```json
{
"orderedItems": [
{
"item": {
"id": 102,
"price": 0.30,
"name": "Banana",
"sideItems": [],
"quantity": 2,
"notes": "ripe ones please"
}
}
],
"userId": "123",
"accountIdentifier": "Table5",
"deliveryTypeId": 2
}
```
### Field Descriptions
| Field | Type | Description |
|-------|------|-------------|
| `orderedItems` | Array | List of ordered items |
| `item.id` | Number | Product ID from catalog |
| `item.price` | Number | Unit price |
| `item.name` | String | Product name |
| `item.sideItems` | Array | Additional items |
| `item.quantity` | Number | Order quantity |
| `item.notes` | String | Special instructions |
| `userId` | String | Operator identifier |
| `accountIdentifier` | String | Table/customer identifier |
| `deliveryTypeId` | Number | Delivery method |
## Customization
### Custom Response Format
Modify the system prompt for different output structures:
```basic
BEGIN SYSTEM PROMPT
Return responses as JSON with this structure:
{
"intent": "order|question|complaint",
"entities": [...extracted entities...],
"response": "...",
"confidence": 0.0-1.0
}
END SYSTEM PROMPT
```
### Adding Validation
```basic
' Validate order before returning
order = LLM_RESPONSE
IF NOT order.orderedItems THEN
RETURN {"error": "No items in order", "suggestion": "Please specify products"}
END IF
FOR EACH item IN order.orderedItems
product = FIND "products.csv", "id = " + item.item.id
IF NOT product THEN
RETURN {"error": "Invalid product ID: " + item.item.id}
END IF
NEXT
RETURN order
```
### Multi-Language Support
```basic
PARAM language AS STRING LIKE "en" DESCRIPTION "Response language"
BEGIN SYSTEM PROMPT
Respond in ${language} language.
Available products: ${TOYAML(products)}
Return JSON format only.
END SYSTEM PROMPT
```
## Error Handling
### Common Error Responses
```json
{
"error": "session_expired",
"message": "Please start a new session",
"code": 401
}
```
```json
{
"error": "invalid_request",
"message": "Missing required parameter: text",
"code": 400
}
```
```json
{
"error": "product_not_found",
"message": "Product 'pizza' is not in our catalog",
"code": 404
}
```
## Best Practices
1. **Keep prompts focused** - Single-purpose system prompts work better
2. **Validate responses** - Always validate LLM output before returning
3. **Handle edge cases** - Plan for invalid products, empty orders
4. **Monitor usage** - Track API calls and response times
5. **Rate limiting** - Implement rate limits for production
6. **Secure endpoints** - Use authentication for production APIs
7. **Log requests** - Maintain audit logs for debugging
## Deployment
### Environment Variables
```bash
LLM_PROVIDER=openai
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4
API_RATE_LIMIT=100
SESSION_TIMEOUT=3600
```
### Docker Deployment
```dockerfile
FROM generalbots/server:latest
COPY llm-server.gbai /app/packages/
ENV API_MODE=true
EXPOSE 4242
CMD ["npm", "start"]
```
## Troubleshooting
| Issue | Cause | Solution |
|-------|-------|----------|
| Empty responses | System prompt too restrictive | Adjust prompt guidelines |
| Invalid JSON | LLM hallucination | Add JSON validation examples |
| Session expired | Timeout reached | Implement session refresh |
| Wrong products | Catalog not loaded | Verify products.csv path |
| Slow responses | Large catalog | Optimize product filtering |
## Related Templates
- `llm-tools.gbai` - LLM with tool/function calling
- `store.gbai` - Full e-commerce with order processing
- `api-client.gbai` - API integration examples
## Use Cases
- **Restaurant Ordering** - Process food orders via API
- **Retail POS Integration** - AI-powered point of sale
- **Chatbot Backend** - Headless chatbot for web/mobile apps
- **Voice Assistant Backend** - Process voice-to-text commands
- **Order Automation** - Automate order entry from various sources
## License
AGPL-3.0 - Part of General Bots Open Source Platform.
---
**Pragmatismo** - General Bots