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:
parent
e8d171ea40
commit
ad311944b8
15 changed files with 2256 additions and 2597 deletions
2757
Cargo.lock
generated
2757
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
15
Cargo.toml
15
Cargo.toml
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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"))?;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue