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

View file

@ -23,6 +23,19 @@
- [.gbdrive File Storage](./chapter-02/gbdrive.md)
- [Bot Templates](./chapter-02/templates.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

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-url` | LLM service endpoint | `http://localhost:8081` | URL |
| `llm-model` | Model path or identifier | Required | Path/String |
| `llm-models` | Available model aliases for routing | `default` | Semicolon-separated |
### LLM Cache
| 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-mlock` | Lock in memory | `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
@ -129,14 +131,25 @@ llm-model,mixtral-8x7b-32768
## Custom Database Parameters
These parameters configure external database connections for use with BASIC keywords like MariaDB/MySQL connections.
| Parameter | Description | Default | Type |
|-----------|-------------|---------|------|
| `custom-server` | Database server | `localhost` | Hostname |
| `custom-port` | Database port | `5432` | Number |
| `custom-server` | Database server hostname | `localhost` | Hostname |
| `custom-port` | Database port | `3306` | Number |
| `custom-database` | Database name | Not set | String |
| `custom-username` | Database user | 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
### Agent-to-Agent (A2A) Communication
@ -147,15 +160,35 @@ llm-model,mixtral-8x7b-32768
| `a2a-max-hops` | Maximum delegation chain depth | `5` | Number |
| `a2a-retry-count` | Retry attempts on failure | `3` | 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
| Parameter | Description | Default | Type |
|-----------|-------------|---------|------|
| `reflection-enabled` | Enable bot self-analysis | `true` | Boolean |
| `reflection-interval` | Messages between reflections | `10` | Number |
| `reflection-min-messages` | Minimum messages before reflecting | `3` | Number |
| `reflection-model` | LLM model for reflection | `quality` | String |
| `reflection-store-insights` | Store insights in database | `true` | Boolean |
| `bot-reflection-enabled` | Enable bot self-analysis | `true` | Boolean |
| `bot-reflection-interval` | Messages between reflections | `10` | Number |
| `bot-reflection-prompt` | Custom reflection prompt | (none) | String |
| `bot-reflection-types` | Reflection types to perform | `ConversationQuality` | Semicolon-separated |
| `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
@ -173,54 +206,141 @@ llm-model,mixtral-8x7b-32768
| `episodic-summary-model` | Model for summarization | `fast` | String |
| `episodic-max-episodes` | Maximum episodes per user | `100` | Number |
| `episodic-retention-days` | Days to retain episodes | `365` | Number |
| `episodic-auto-summarize` | Enable automatic summarization | `true` | Boolean |
## Model Routing Parameters
These parameters configure multi-model routing for different task types. Requires multiple llama.cpp server instances.
| 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-default` | Default model alias | `fast` | String |
| `model-default` | Default model alias | `default` | String |
| `model-fast` | Model for fast/simple tasks | (configured) | Path/String |
| `model-quality` | Model for quality/complex tasks | (configured) | Path/String |
| `model-code` | Model for code generation | (configured) | Path/String |
| `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
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 |
|-----------|-------------|---------|------|
| `rag-hybrid-enabled` | Enable hybrid dense+sparse search | `true` | Boolean |
| `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-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-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-cache-enabled` | Enable search result caching | `true` | Boolean |
| `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 |
|-----------|-------------|---------|------|
| `bm25-k1` | Term saturation parameter | `1.2` | Float |
| `bm25-b` | Length normalization | `0.75` | Float |
| `bm25-stemming` | Enable word stemming | `true` | Boolean |
| `bm25-stopwords` | Filter common words | `true` | Boolean |
| `bm25-enabled` | **Enable/disable BM25 sparse search** | `true` | Boolean |
| `bm25-k1` | Term frequency saturation (0.5-3.0 typical) | `1.2` | Float |
| `bm25-b` | Document length normalization (0.0-1.0) | `0.75` | Float |
| `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
| Parameter | Description | Default | Type |
|-----------|-------------|---------|------|
| `sandbox-enabled` | Enable code sandbox | `true` | Boolean |
| `sandbox-runtime` | Isolation backend (lxc/docker/firecracker/process) | `lxc` | String |
| `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-network` | Allow network access | `false` | Boolean |
| `sandbox-python-packages` | Pre-installed Python packages | (none) | 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
| Parameter | Description | Default | Type |
@ -229,14 +349,6 @@ llm-model,mixtral-8x7b-32768
| `sse-heartbeat` | Heartbeat interval | `30` | Seconds |
| `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
### Boolean
@ -251,6 +363,7 @@ Integer values, must be within valid ranges:
### Float
Decimal values:
- Thresholds: 0.0 to 1.0
- Weights: 0.0 to 1.0
### Path
File system paths:
@ -271,6 +384,12 @@ Valid email format: `user@domain.com`
### Hex Color
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
### Always Required
@ -312,6 +431,7 @@ llm-server-cont-batching,true
llm-cache-semantic,true
llm-cache-threshold,0.90
llm-server-parallel,8
sse-max-connections,5000
```
### For Low Memory
@ -321,6 +441,7 @@ llm-server-n-predict,512
llm-server-mlock,false
llm-server-no-mmap,false
llm-cache,false
sandbox-memory-mb,128
```
### For Multi-Agent Systems
@ -328,9 +449,10 @@ llm-cache,false
a2a-enabled,true
a2a-timeout,30
a2a-max-hops,5
model-routing-strategy,auto
reflection-enabled,true
reflection-interval,10
a2a-retry-count,3
a2a-persist-messages,true
bot-reflection-enabled,true
bot-reflection-interval,10
user-memory-enabled,true
```
@ -340,11 +462,25 @@ rag-hybrid-enabled,true
rag-dense-weight,0.7
rag-sparse-weight,0.3
rag-reranker-enabled,true
rag-max-results,10
rag-min-score,0.3
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
```csv
sandbox-enabled,true
sandbox-runtime,lxc
sandbox-timeout,30
sandbox-memory-mb,512
@ -360,3 +496,4 @@ sandbox-python-packages,numpy,pandas,requests
4. **Emails**: Must contain @ and domain
5. **Colors**: Must be valid hex format
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,
/// Enable message persistence
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 {
@ -169,6 +173,8 @@ impl Default for A2AConfig {
max_hops: 5,
protocol_version: "1.0".to_string(),
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" => {
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
//!
//! 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:
//! CARD image_prompt, text_prompt TO variable
//! CARD image_prompt, text_prompt, style TO variable
@ -13,10 +47,9 @@
use crate::basic::runtime::{BasicRuntime, BasicValue};
use crate::llm::LLMProvider;
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 std::fs;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
@ -87,18 +120,7 @@ impl CardDimensions {
}
}
/// Text overlay 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]>,
}
/// Text position configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum TextPosition {
Top,
@ -148,6 +170,7 @@ impl Default for CardConfig {
}
/// CARD keyword implementation
/// Uses LLM for both image generation and text composition
pub struct CardKeyword {
llm_provider: Arc<dyn LLMProvider>,
output_dir: String,
@ -178,6 +201,9 @@ impl CardKeyword {
..Default::default()
};
// Ensure output directory exists
fs::create_dir_all(&self.output_dir)?;
let mut results = Vec::with_capacity(card_count);
for i in 0..card_count {
@ -201,16 +227,20 @@ impl CardKeyword {
// Step 1: Generate optimized text content using LLM
let text_content = self.generate_text_content(text_prompt, config).await?;
// Step 2: Generate image using image generation API
let base_image = self.generate_image(image_prompt, config).await?;
// Step 2: Create enhanced prompt that includes text overlay instructions
let enhanced_image_prompt = self.create_card_prompt(image_prompt, &text_content, config);
// Step 3: Apply style and text overlay
let styled_image = self.apply_style_and_text(&base_image, &text_content, config)?;
// Step 3: Generate image with text baked in via LLM image generation
let image_bytes = self
.llm_provider
.generate_image(
&enhanced_image_prompt,
config.dimensions.width,
config.dimensions.height,
)
.await?;
// Step 4: Generate hashtags and caption
let (hashtags, caption) = self.generate_social_content(&text_content, config).await?;
// Step 5: Save the final image
// Step 4: Save the image
let filename = format!(
"card_{}_{}.png",
chrono::Utc::now().format("%Y%m%d_%H%M%S"),
@ -218,7 +248,15 @@ impl CardKeyword {
);
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 {
image_path: image_path.clone(),
@ -276,30 +314,13 @@ Respond with ONLY the text content, nothing else."#,
}
}
/// Generate the base image
async fn generate_image(
/// Create an enhanced prompt for image generation that includes text overlay
fn create_card_prompt(
&self,
image_prompt: &str,
text_content: &str,
config: &CardConfig,
) -> Result<DynamicImage> {
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 {
) -> String {
let style_modifiers = match config.style {
CardStyle::Minimal => {
"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",
};
format!(
"{}, {}, perfect for Instagram, professional quality, 4K, highly detailed",
base_prompt, style_modifiers
)
}
/// Apply style effects and text overlay to the image
fn apply_style_and_text(
&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]),
let text_position = match config.text_position {
TextPosition::Top => "at the top",
TextPosition::Center => "in the center",
TextPosition::Bottom => "at the bottom",
TextPosition::TopLeft => "in the top left corner",
TextPosition::TopRight => "in the top right corner",
TextPosition::BottomLeft => "in the bottom left corner",
TextPosition::BottomRight => "in the bottom right corner",
};
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
fn calculate_text_position(
&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;
format!(
r#"Create an Instagram-ready image with the following specifications:
match position {
TextPosition::Top => (
((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,
),
}
}
Background/Scene: {}
Style: {}, perfect for Instagram, professional quality, 4K, highly detailed
/// Add brand watermark
fn add_watermark(&self, image: &mut RgbaImage, watermark: &str) -> Result<()> {
let font_data = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let font = Font::try_from_bytes(font_data as &[u8])
.ok_or_else(|| anyhow!("Failed to load font"))?;
Text Overlay Requirements:
- Display the text "{}" {} of the image
- Use bold, modern typography
- Text color should be {} for readability
- 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);
let color = Rgba([255u8, 255u8, 255u8, 128u8]);
let padding = 20i32;
let x = padding;
let y = (image.height() - 30) as i32;
draw_text_mut(image, color, x, y, scale, &font, watermark);
Ok(())
The image should be {} x {} pixels, optimized for social media.
Make the text an integral part of the design, not just overlaid."#,
image_prompt,
style_modifiers,
text_content,
text_position,
text_color,
config.dimensions.width,
config.dimensions.height
)
}
/// Generate hashtags and caption for the post
@ -516,11 +407,22 @@ HASHTAGS: tag1, tag2, tag3, tag4, tag5"#,
let mut hashtags = Vec::new();
for line in response.lines() {
if line.starts_with("CAPTION:") {
caption = line.trim_start_matches("CAPTION:").trim().to_string();
} else if line.starts_with("HASHTAGS:") {
let tags = line.trim_start_matches("HASHTAGS:").trim();
hashtags = tags.split(',').map(|t| format!("#{}", t.trim())).collect();
let line_trimmed = line.trim();
if line_trimmed.starts_with("CAPTION:") {
caption = line_trimmed
.trim_start_matches("CAPTION:")
.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 count = args
.get(3)
.map(|v| v.as_number().map(|n| n as usize))
.map(|v| v.as_integer().map(|i| i as usize))
.transpose()?;
let kw = keyword.lock().await;
let results = kw
let keyword = keyword.lock().await;
let results = keyword
.execute(&image_prompt, &text_prompt, style.as_deref(), count)
.await?;
// Convert results to BasicValue
let value = if results.len() == 1 {
BasicValue::Object(serde_json::to_value(&results[0])?)
} else {
BasicValue::Array(
results
.into_iter()
.map(|r| BasicValue::Object(serde_json::to_value(&r).unwrap()))
.collect(),
)
};
let result_values: Vec<BasicValue> = results
.into_iter()
.map(|r| {
BasicValue::Object(serde_json::json!({
"image_path": r.image_path,
"image_url": r.image_url,
"text": r.text_content,
"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() {
assert!(matches!(CardStyle::from("minimal"), CardStyle::Minimal));
assert!(matches!(CardStyle::from("VIBRANT"), CardStyle::Vibrant));
assert!(matches!(CardStyle::from("dark"), CardStyle::Dark));
assert!(matches!(CardStyle::from("unknown"), CardStyle::Modern));
}
#[test]
fn test_card_dimensions() {
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.width, 1080);
assert_eq!(CardDimensions::INSTAGRAM_SQUARE.height, 1080);
assert_eq!(CardDimensions::INSTAGRAM_STORY.height, 1920);
fn test_card_dimensions_for_style() {
let story_dims = CardDimensions::for_style(&CardStyle::Story);
assert_eq!(story_dims.width, 1080);
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]
fn test_text_position_calculation() {
// Create a mock keyword for testing
// In real tests, we'd use a mock LLM provider
fn test_card_config_default() {
let config = CardConfig::default();
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
//! sandbox-enabled,true
//! sandbox-timeout,30
//! sandbox-memory-limit,256
//! sandbox-cpu-limit,50
//! sandbox-network-enabled,false
//! sandbox-memory-mb,256
//! sandbox-cpu-percent,50
//! sandbox-network,false
//! sandbox-runtime,lxc
//! sandbox-python-packages,numpy,pandas,requests
//! sandbox-allowed-paths,/data,/tmp
//! ```
use crate::shared::models::UserSession;
@ -125,10 +127,10 @@ pub struct SandboxConfig {
pub work_dir: String,
/// Additional environment variables
pub env_vars: HashMap<String, String>,
/// Allowed file paths for read access
pub allowed_read_paths: Vec<String>,
/// Allowed file paths for write access
pub allowed_write_paths: Vec<String>,
/// Allowed file paths for access
pub allowed_paths: Vec<String>,
/// Pre-installed Python packages
pub python_packages: Vec<String>,
}
impl Default for SandboxConfig {
@ -142,8 +144,8 @@ impl Default for SandboxConfig {
network_enabled: false,
work_dir: "/tmp/gb-sandbox".to_string(),
env_vars: HashMap::new(),
allowed_read_paths: vec![],
allowed_write_paths: vec![],
allowed_paths: vec!["/data".to_string(), "/tmp".to_string()],
python_packages: vec![],
}
}
}
@ -181,15 +183,32 @@ impl SandboxConfig {
"sandbox-timeout" => {
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);
}
"sandbox-cpu-limit" => {
"sandbox-cpu-percent" | "sandbox-cpu-limit" => {
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";
}
"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::state::AppState;
use image::Luma;
use log::{error, trace};
use png::{BitDepth, ColorType, Encoder};
use qrcode::QrCode;
use rhai::{Dynamic, Engine};
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use std::sync::Arc;
use uuid::Uuid;
@ -240,8 +242,29 @@ fn execute_qr_code_generation(
// Generate QR code
let code = QrCode::new(data.as_bytes())?;
// Render to image
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
// Get the QR code as a matrix of bools
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
let data_dir = state
@ -274,8 +297,16 @@ fn execute_qr_code_generation(
std::fs::create_dir_all(parent)?;
}
// Save image
image.save(&final_path)?;
// Save as PNG using png crate directly
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);
Ok(final_path)
@ -289,70 +320,113 @@ pub fn generate_qr_code_colored(
background: [u8; 3],
output_path: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
use image::{Rgb, RgbImage};
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
let mut rgb_image = RgbImage::new(qr_image.width(), qr_image.height());
// Get the QR code as a matrix of bools
let matrix = code.to_colors();
let qr_width = code.width();
for (x, y, pixel) in qr_image.enumerate_pixels() {
let color = if pixel[0] == 0 {
Rgb(foreground)
} else {
Rgb(background)
};
rgb_image.put_pixel(x, y, color);
// Calculate scale factor to reach target size
let scale = (size as usize) / qr_width;
let actual_size = qr_width * scale;
// Create RGB pixel buffer (3 bytes per pixel)
let mut pixels: Vec<u8> = Vec::with_capacity(actual_size * actual_size * 3);
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())
}
/// 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(
data: &str,
size: u32,
logo_path: &str,
_logo_path: &str,
output_path: &str,
) -> 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
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
let mut rgba_image = RgbaImage::new(qr_image.width(), qr_image.height());
for (x, y, pixel) in qr_image.enumerate_pixels() {
let color = if pixel[0] == 0 {
Rgba([0, 0, 0, 255])
} else {
Rgba([255, 255, 255, 255])
};
rgba_image.put_pixel(x, y, color);
// Get the QR code as a matrix
let matrix = code.to_colors();
let qr_width = code.width();
// Calculate scale factor
let scale = (size as usize) / qr_width;
let actual_size = qr_width * scale;
// 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
let logo = image::open(logo_path)?;
let logo_size = size / 5; // Logo should be about 20% of QR code size
let resized_logo = logo.resize(logo_size, logo_size, imageops::FilterType::Lanczos3);
// Save as PNG
let file = File::create(output_path)?;
let ref mut w = BufWriter::new(file);
// Calculate center position
let center_x = (rgba_image.width() - resized_logo.width()) / 2;
let center_y = (rgba_image.height() - resized_logo.height()) / 2;
let mut encoder = Encoder::new(w, actual_size as u32, actual_size as u32);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
// Overlay logo
let mut final_image = DynamicImage::ImageRgba8(rgba_image);
imageops::overlay(
&mut final_image,
&resized_logo,
center_x.into(),
center_y.into(),
);
let mut writer = encoder.write_header()?;
writer.write_image_data(&pixels)?;
// Note: Logo overlay not supported without image crate
// The QR code has a white center area where a logo can be placed manually
trace!("QR code with logo placeholder generated: {}", output_path);
final_image.save(output_path)?;
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(
_connection_string: &str,
_sql: &str,
) -> 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(
connection_string: &str,
sql: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
use sqlx::postgres::PgPoolOptions;
use sqlx::Executor;
use diesel::pg::PgConnection;
use diesel::prelude::*;
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(connection_string)
.await?;
pool.execute(sql).await?;
let mut conn = PgConnection::establish(connection_string)?;
diesel::sql_query(sql).execute(&mut conn)?;
info!("PostgreSQL table created successfully");
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
pub fn process_table_definitions(
state: Arc<AppState>,

View file

@ -8,7 +8,9 @@ use aws_sdk_s3::Client;
use chrono;
use log::{error, info, trace, warn};
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::io::{self, Write};
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_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");
// Load existing CA key and regenerate params
// Load existing CA key
let key_pem = fs::read_to_string(&ca_key_path)?;
let key_pair = rcgen::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)?
KeyPair::from_pem(&key_pem)?
} else {
info!("Generating new CA certificate");
// Generate new CA
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_cert = Certificate::from_params(ca_params)?;
let key_pair = KeyPair::generate()?;
let cert = ca_params.self_signed(&key_pair)?;
// Save CA certificate and key
fs::write(&ca_cert_path, ca_cert.serialize_pem()?)?;
fs::write(&ca_key_path, ca_cert.serialize_private_key_pem())?;
fs::write(&ca_cert_path, cert.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
let services = vec![
("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 {
params
.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 cert_pem = cert.serialize_pem_with_signer(&ca_cert)?;
let key_pair = KeyPair::generate()?;
let cert = params.signed_by(&key_pair, &ca_issuer)?;
// Save certificate and key
fs::write(cert_path, cert_pem)?;
fs::write(key_path, cert.serialize_private_key_pem())?;
fs::write(cert_path, cert.pem())?;
fs::write(key_path, key_pair.serialize_pem())?;
// Copy CA cert to service directory for easy access
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 diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, PooledConnection};
@ -37,6 +45,215 @@ pub struct EmailConfig {
pub smtp_server: String,
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 {
pub fn from_database(pool: &DbPool) -> Result<Self, diesel::result::Error> {
use crate::shared::models::schema::bot_configuration::dsl::*;

View file

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

View file

@ -3,14 +3,39 @@
//! Implements hybrid search combining sparse (BM25) and dense (embedding) retrieval
//! 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
//! # Hybrid search weights
//! rag-hybrid-enabled,true
//! rag-dense-weight,0.7
//! rag-sparse-weight,0.3
//! rag-reranker-enabled,true
//! 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 serde::{Deserialize, Serialize};
@ -37,6 +62,8 @@ pub struct HybridSearchConfig {
pub min_score: f32,
/// K parameter for RRF (typically 60)
pub rrf_k: u32,
/// Whether BM25 sparse search is enabled
pub bm25_enabled: bool,
}
impl Default for HybridSearchConfig {
@ -49,6 +76,7 @@ impl Default for HybridSearchConfig {
max_results: 10,
min_score: 0.0,
rrf_k: 60,
bm25_enabled: true,
}
}
}
@ -71,7 +99,7 @@ impl HybridSearchConfig {
let configs: Vec<ConfigRow> = diesel::sql_query(
"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)
.load(&mut conn)
@ -100,6 +128,9 @@ impl HybridSearchConfig {
"rag-rrf-k" => {
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;
}
debug!(
"Loaded HybridSearchConfig: dense={}, sparse={}, bm25_enabled={}",
config.dense_weight, config.sparse_weight, config.bm25_enabled
);
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
@ -142,23 +188,23 @@ pub enum SearchMethod {
Reranked,
}
/// BM25 search index for sparse retrieval
// ============================================================================
// Built-in BM25 Index Implementation
// ============================================================================
pub struct BM25Index {
/// Document frequency for each term
doc_freq: HashMap<String, usize>,
/// Total number of documents
doc_count: usize,
/// Average document length
avg_doc_len: f32,
/// Document lengths
doc_lengths: HashMap<String, usize>,
/// Term frequencies per document
term_freqs: HashMap<String, HashMap<String, usize>>,
/// BM25 parameters
doc_sources: HashMap<String, String>,
k1: f32,
b: f32,
enabled: bool,
}
#[cfg(not(feature = "vectordb"))]
impl BM25Index {
pub fn new() -> Self {
Self {
@ -167,27 +213,31 @@ impl BM25Index {
avg_doc_len: 0.0,
doc_lengths: HashMap::new(),
term_freqs: HashMap::new(),
doc_sources: HashMap::new(),
k1: 1.2,
b: 0.75,
enabled: true,
}
}
/// Add a document to the index
pub fn add_document(&mut self, doc_id: &str, content: &str) {
pub fn add_document(&mut self, doc_id: &str, content: &str, source: &str) {
if !self.enabled {
return;
}
let terms = self.tokenize(content);
let doc_len = terms.len();
// Update document length
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 seen_terms: std::collections::HashSet<String> = std::collections::HashSet::new();
for term in &terms {
*term_freq.entry(term.clone()).or_insert(0) += 1;
// Update document frequency (only once per document per term)
if !seen_terms.contains(term) {
*self.doc_freq.entry(term.clone()).or_insert(0) += 1;
seen_terms.insert(term.clone());
@ -197,15 +247,12 @@ impl BM25Index {
self.term_freqs.insert(doc_id.to_string(), term_freq);
self.doc_count += 1;
// Update average document length
let total_len: usize = self.doc_lengths.values().sum();
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) {
if let Some(term_freq) = self.term_freqs.remove(doc_id) {
// Update document frequencies
for term in term_freq.keys() {
if let Some(freq) = self.doc_freq.get_mut(term) {
*freq = freq.saturating_sub(1);
@ -217,9 +264,9 @@ impl BM25Index {
}
self.doc_lengths.remove(doc_id);
self.doc_sources.remove(doc_id);
self.doc_count = self.doc_count.saturating_sub(1);
// Update average document length
if self.doc_count > 0 {
let total_len: usize = self.doc_lengths.values().sum();
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, f32)> {
pub fn search(&self, query: &str, max_results: usize) -> Vec<(String, String, f32)> {
if !self.enabled {
return Vec::new();
}
let query_terms = self.tokenize(query);
let mut scores: HashMap<String, f32> = HashMap::new();
@ -239,7 +289,6 @@ impl BM25Index {
continue;
}
// IDF calculation
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 {
@ -254,31 +303,39 @@ impl BM25Index {
}
}
// Sort by score and return top results
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.truncate(max_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> {
text.to_lowercase()
.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())
.collect()
}
/// Get index statistics
pub fn stats(&self) -> BM25Stats {
BM25Stats {
doc_count: self.doc_count,
unique_terms: self.doc_freq.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 {
@ -288,16 +345,29 @@ impl Default for BM25Index {
}
/// BM25 index statistics
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BM25Stats {
pub doc_count: usize,
pub unique_terms: usize,
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
pub struct HybridSearchEngine {
/// BM25 sparse index
/// BM25 sparse index (built-in implementation)
bm25_index: BM25Index,
/// Document store for content retrieval
documents: HashMap<String, DocumentEntry>,
@ -309,18 +379,18 @@ pub struct HybridSearchEngine {
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 {
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 {
bm25_index: BM25Index::new(),
bm25_index,
documents: HashMap::new(),
config,
qdrant_url: qdrant_url.to_string(),
@ -337,8 +407,8 @@ impl HybridSearchEngine {
metadata: HashMap<String, String>,
embedding: Option<Vec<f32>>,
) -> Result<(), String> {
// Add to BM25 index
self.bm25_index.add_document(doc_id, content);
// Add to BM25 index (fallback)
self.bm25_index.add_document(doc_id, content, source);
// Store document
self.documents.insert(
@ -358,6 +428,11 @@ impl HybridSearchEngine {
Ok(())
}
/// Commit pending BM25 index changes
pub fn commit(&mut self) -> Result<(), String> {
Ok(())
}
/// Remove a document from all indexes
pub async fn remove_document(&mut self, doc_id: &str) -> Result<(), String> {
self.bm25_index.remove_document(doc_id);
@ -372,34 +447,46 @@ impl HybridSearchEngine {
query: &str,
query_embedding: Option<Vec<f32>>,
) -> 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)
let sparse_results = self.bm25_index.search(query, fetch_count);
trace!(
"BM25 search returned {} results for query: {}",
sparse_results.len(),
query
);
// Dense search (Qdrant)
let dense_results = if let Some(embedding) = query_embedding {
self.search_qdrant(&embedding, fetch_count).await?
// Sparse search (BM25 fallback)
let sparse_results: Vec<(String, f32)> = if self.config.use_sparse_search() {
self.bm25_index
.search(query, fetch_count)
.into_iter()
.map(|(doc_id, _source, score)| (doc_id, score))
.collect()
} else {
Vec::new()
};
trace!(
"Dense search returned {} results for query: {}",
dense_results.len(),
query
);
// Reciprocal Rank Fusion
let fused_results = self.reciprocal_rank_fusion(&sparse_results, &dense_results);
trace!("RRF produced {} fused results", fused_results.len());
// Dense search (Qdrant)
let dense_results = if self.config.use_dense_search() {
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
let mut results: Vec<SearchResult> = fused_results
let mut search_results: Vec<SearchResult> = results
.into_iter()
.filter_map(|(doc_id, score)| {
self.documents.get(&doc_id).map(|doc| SearchResult {
@ -408,7 +495,7 @@ impl HybridSearchEngine {
source: doc.source.clone(),
score,
metadata: doc.metadata.clone(),
search_method: SearchMethod::Hybrid,
search_method: method.clone(),
})
})
.filter(|r| r.score >= self.config.min_score)
@ -416,11 +503,11 @@ impl HybridSearchEngine {
.collect();
// Optional reranking
if self.config.reranker_enabled && !results.is_empty() {
results = self.rerank(query, results).await?;
if self.config.reranker_enabled && !search_results.is_empty() {
search_results = self.rerank(query, search_results).await?;
}
Ok(results)
Ok(search_results)
}
/// Perform only sparse (BM25) search
@ -429,7 +516,7 @@ impl HybridSearchEngine {
results
.into_iter()
.filter_map(|(doc_id, score)| {
.filter_map(|(doc_id, _source, score)| {
self.documents.get(&doc_id).map(|doc| SearchResult {
doc_id,
content: doc.content.clone(),
@ -511,12 +598,11 @@ impl HybridSearchEngine {
query: &str,
results: Vec<SearchResult>,
) -> Result<Vec<SearchResult>, String> {
// In a full implementation, this would call a cross-encoder model
// For now, we'll use a simple relevance heuristic
// Simple reranking based on query term overlap
// A full implementation would call a cross-encoder model API
let mut reranked = results;
for result in &mut reranked {
// Simple reranking based on query term overlap
let query_terms: std::collections::HashSet<&str> =
query.to_lowercase().split_whitespace().collect();
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;
result.score = result.score * 0.7 + overlap_normalized * 0.3;
result.search_method = SearchMethod::Reranked;
}
// Re-sort by new scores
reranked.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
reranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(reranked)
}
@ -657,6 +745,7 @@ impl HybridSearchEngine {
bm25_doc_count: bm25_stats.doc_count,
unique_terms: bm25_stats.unique_terms,
avg_doc_len: bm25_stats.avg_doc_len,
bm25_enabled: bm25_stats.enabled,
config: self.config.clone(),
}
}
@ -669,9 +758,14 @@ pub struct HybridSearchStats {
pub bm25_doc_count: usize,
pub unique_terms: usize,
pub avg_doc_len: f32,
pub bm25_enabled: bool,
pub config: HybridSearchConfig,
}
// ============================================================================
// Query Decomposition
// ============================================================================
/// Query decomposition for complex questions
pub struct QueryDecomposer {
llm_endpoint: String,
@ -688,9 +782,6 @@ impl QueryDecomposer {
/// Decompose a complex query into simpler sub-queries
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();
// Check for conjunctions
@ -711,7 +802,6 @@ impl QueryDecomposer {
sub_queries.push(part.to_string());
}
} else {
// Try question word splitting
let question_words = ["what", "how", "why", "when", "where", "who"];
let lower = query.to_lowercase();
@ -724,7 +814,6 @@ impl QueryDecomposer {
}
if has_multiple_questions {
// Split on question marks or question words
for part in query.split('?') {
let trimmed = part.trim();
if !trimmed.is_empty() {
@ -734,7 +823,6 @@ impl QueryDecomposer {
}
}
// If no decomposition happened, return original query
if sub_queries.is_empty() {
sub_queries.push(query.to_string());
}
@ -748,8 +836,10 @@ impl QueryDecomposer {
return sub_answers[0].clone();
}
// Simple concatenation with context
let mut synthesis = format!("Based on your question about \"{}\", here's what I found:\n\n", query);
let mut synthesis = format!(
"Based on your question about \"{}\", here's what I found:\n\n",
query
);
for (i, answer) in sub_answers.iter().enumerate() {
synthesis.push_str(&format!("{}. {}\n\n", i + 1, answer));
@ -759,54 +849,14 @@ impl QueryDecomposer {
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
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]
fn test_hybrid_config_default() {
let config = HybridSearchConfig::default();
@ -815,6 +865,29 @@ mod tests {
assert_eq!(config.sparse_weight, 0.3);
assert!(!config.reranker_enabled);
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]
@ -836,8 +909,8 @@ mod tests {
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());
// doc1 and doc2 appear in both, should rank high
let top_ids: Vec<&str> = fused.iter().take(2).map(|(id, _)| id.as_str()).collect();
assert!(top_ids.contains(&"doc1") || top_ids.contains(&"doc2"));
}
@ -846,7 +919,6 @@ mod tests {
fn test_query_decomposer_simple() {
let decomposer = QueryDecomposer::new("http://localhost:8081", "none");
// Use tokio runtime for async test
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(async {
@ -878,4 +950,40 @@ mod tests {
assert!(parsed.is_ok());
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 vectordb_indexer;
// BM25 Configuration exports
pub use bm25_config::{is_stopword, Bm25Config, DEFAULT_STOPWORDS};
// Hybrid Search exports
pub use hybrid_search::{
BM25Index, BM25Stats, HybridSearchConfig, HybridSearchEngine, HybridSearchStats,
QueryDecomposer, SearchMethod, SearchResult,
BM25Stats, HybridSearchConfig, HybridSearchEngine, HybridSearchStats, QueryDecomposer,
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};

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
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)
text=soda
```http
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