mirror of
https://github.com/perstarkse/minne.git
synced 2026-05-29 19:00:51 +02:00
chore: harden system settings and unify prompt usage
Validate settings updates, use typed embedding backends, and route ingestion through DB-stored prompts so admin edits take effect.
This commit is contained in:
@@ -19,6 +19,6 @@ CREATE system_settings:current CONTENT {
|
||||
image_processing_prompt: "Analyze this image and respond based on its primary content:\n - If the image is mainly text (document, screenshot, sign), transcribe the text verbatim.\n - If the image is mainly visual (photograph, art, landscape), provide a concise description of the scene.\n - For hybrid images (diagrams, ads), briefly describe the visual, then transcribe the text under a Text: heading.\n\n Respond directly with the analysis.",
|
||||
embedding_dimensions: 1536,
|
||||
query_system_prompt: "You are a knowledgeable assistant with access to a specialized knowledge base. You will be provided with relevant knowledge entities from the database as context. Each knowledge entity contains a name, description, and type, representing different concepts, ideas, and information.\nYour task is to:\n1. Carefully analyze the provided knowledge entities in the context\n2. Answer user questions based on this information\n3. Provide clear, concise, and accurate responses\n4. When referencing information, briefly mention which knowledge entity it came from\n5. If the provided context doesn't contain enough information to answer the question confidently, clearly state this\n6. If only partial information is available, explain what you can answer and what information is missing\n7. Avoid making assumptions or providing information not supported by the context\n8. Output the references to the documents. Use the UUIDs and make sure they are correct!\nRemember:\n- Be direct and honest about the limitations of your knowledge\n- Cite the relevant knowledge entities when providing information, but only provide the UUIDs in the reference array\n- If you need to combine information from multiple entities, explain how they connect\n- Don't speculate beyond what's provided in the context\nExample response formats:\n\"Based on [Entity Name], [answer...]\"\n\"I found relevant information in multiple entries: [explanation...]\"\n\"I apologize, but the provided context doesn't contain information about [topic]\"",
|
||||
ingestion_system_prompt: "You are an AI assistant. You will receive a text content, along with user context and a category. Your task is to provide a structured JSON object representing the content in a graph format suitable for a graph database. You will also be presented with some existing knowledge_entities from the database, do not replicate these! Your task is to create meaningful knowledge entities from the submitted content. Try and infer as much as possible from the users context and category when creating these. If the user submits a large content, create more general entities. If the user submits a narrow and precise content, try and create precise knowledge entities.\nThe JSON should have the following structure:\n{\n\"knowledge_entities\": [\n{\n\"key\": \"unique-key-1\",\n\"name\": \"Entity Name\",\n\"description\": \"A detailed description of the entity.\",\n\"entity_type\": \"TypeOfEntity\"\n},\n// More entities...\n],\n\"relationships\": [\n{\n\"type\": \"RelationshipType\",\n\"source\": \"unique-key-1 or UUID from existing database\",\n\"target\": \"unique-key-1 or UUID from existing database\"\n},\n// More relationships...\n]\n}\nGuidelines:\n1. Do NOT generate any IDs or UUIDs. Use a unique `key` for each knowledge entity.\n2. Each KnowledgeEntity should have a unique `key`, a meaningful `name`, and a descriptive `description`.\n3. Define the type of each KnowledgeEntity using the following categories: Idea, Project, Document, Page, TextSnippet.\n4. Establish relationships between entities using types like RelatedTo, RelevantTo, SimilarTo.\n5. Use the `source` key to indicate the originating entity and the `target` key to indicate the related entity\"\n6. You will be presented with a few existing KnowledgeEntities that are similar to the current ones. They will have an existing UUID. When creating relationships to these entities, use their UUID.\n7. Only create relationships between existing KnowledgeEntities.\n8. Entities that exist already in the database should NOT be created again. If there is only a minor overlap, skip creating a new entity.\n9. A new relationship MUST include a newly created KnowledgeEntity."
|
||||
ingestion_system_prompt: "You are an AI assistant. You will receive a text content, along with user context and a category. Your task is to provide a structured JSON object representing the content in a graph format suitable for a graph database. You will also be presented with some existing knowledge_entities from the database, do not replicate these! Your task is to create meaningful knowledge entities from the submitted content. Try and infer as much as possible from the users context and category when creating these. If the user submits a large content, create more general entities. If the user submits a narrow and precise content, try and create precise knowledge entities.\nThe JSON should have the following structure:\n{\n\"knowledge_entities\": [\n{\n\"key\": \"unique-key-1\",\n\"name\": \"Entity Name\",\n\"description\": \"A detailed description of the entity.\",\n\"entity_type\": \"TypeOfEntity\"\n},\n// More entities...\n],\n\"relationships\": [\n{\n\"type\": \"RelationshipType\",\n\"source\": \"unique-key-1 or UUID from existing database\",\n\"target\": \"unique-key-1 or UUID from existing database\"\n},\n// More relationships...\n]\n}\nGuidelines:\n1. Do NOT generate any IDs or UUIDs. Use a unique `key` for each knowledge entity.\n2. Each KnowledgeEntity should have a unique `key`, a meaningful `name`, and a descriptive `description`.\n3. Define the type of each KnowledgeEntity using the following categories: Idea, Project, Document, Page, TextSnippet.\n4. Establish relationships between entities using types like RelatedTo, RelevantTo, SimilarTo.\n5. Use the `source` key to indicate the originating entity and the `target` key to indicate the related entity.\n6. You will be presented with a few existing KnowledgeEntities that are similar to the current ones. They will have an existing UUID. When creating relationships to these entities, use their UUID.\n7. Only create relationships between existing KnowledgeEntities.\n8. Entities that exist already in the database should NOT be created again. If there is only a minor overlap, skip creating a new entity.\n9. A new relationship MUST include a newly created KnowledgeEntity."
|
||||
};
|
||||
END;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub static DEFAULT_QUERY_SYSTEM_PROMPT: &str = r#"You are a knowledgeable assistant with access to a specialized knowledge base. You will be provided with relevant knowledge entities from the database as context. Each knowledge entity contains a name, description, and type, representing different concepts, ideas, and information.
|
||||
pub const DEFAULT_QUERY_SYSTEM_PROMPT: &str = r#"You are a knowledgeable assistant with access to a specialized knowledge base. You will be provided with relevant knowledge entities from the database as context. Each knowledge entity contains a name, description, and type, representing different concepts, ideas, and information.
|
||||
|
||||
Your task is to:
|
||||
1. Carefully analyze the provided knowledge entities in the context
|
||||
@@ -20,7 +20,7 @@ Example response formats:
|
||||
"I found relevant information in multiple entries: [explanation...]"
|
||||
"I apologize, but the provided context doesn't contain information about [topic]""#;
|
||||
|
||||
pub static DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT: &str = r#"You are an AI assistant. You will receive a text content, along with user context and a category. Your task is to provide a structured JSON object representing the content in a graph format suitable for a graph database. You will also be presented with some existing knowledge_entities from the database, do not replicate these! Your task is to create meaningful knowledge entities from the submitted content. Try and infer as much as possible from the users context and category when creating these. If the user submits a large content, create more general entities. If the user submits a narrow and precise content, try and create precise knowledge entities.
|
||||
pub const DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT: &str = r#"You are an AI assistant. You will receive a text content, along with user context and a category. Your task is to provide a structured JSON object representing the content in a graph format suitable for a graph database. You will also be presented with some existing knowledge_entities from the database, do not replicate these! Your task is to create meaningful knowledge entities from the submitted content. Try and infer as much as possible from the users context and category when creating these. If the user submits a large content, create more general entities. If the user submits a narrow and precise content, try and create precise knowledge entities.
|
||||
|
||||
The JSON should have the following structure:
|
||||
|
||||
@@ -49,13 +49,13 @@ Guidelines:
|
||||
2. Each KnowledgeEntity should have a unique `key`, a meaningful `name`, and a descriptive `description`.
|
||||
3. Define the type of each KnowledgeEntity using the following categories: Idea, Project, Document, Page, TextSnippet.
|
||||
4. Establish relationships between entities using types like RelatedTo, RelevantTo, SimilarTo.
|
||||
5. Use the `source` key to indicate the originating entity and the `target` key to indicate the related entity"
|
||||
5. Use the `source` key to indicate the originating entity and the `target` key to indicate the related entity.
|
||||
6. You will be presented with a few existing KnowledgeEntities that are similar to the current ones. They will have an existing UUID. When creating relationships to these entities, use their UUID.
|
||||
7. Only create relationships between existing KnowledgeEntities.
|
||||
8. Entities that exist already in the database should NOT be created again. If there is only a minor overlap, skip creating a new entity.
|
||||
9. A new relationship MUST include a newly created KnowledgeEntity."#;
|
||||
|
||||
pub static DEFAULT_IMAGE_PROCESSING_PROMPT: &str = r#"Analyze this image and respond based on its primary content:
|
||||
pub const DEFAULT_IMAGE_PROCESSING_PROMPT: &str = r#"Analyze this image and respond based on its primary content:
|
||||
- If the image is mainly text (document, screenshot, sign), transcribe the text verbatim.
|
||||
- If the image is mainly visual (photograph, art, landscape), provide a concise description of the scene.
|
||||
- For hybrid images (diagrams, ads), briefly describe the visual, then transcribe the text under a "Text:" heading.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::utils::embedding::EmbeddingBackend;
|
||||
use crate::utils::serde_helpers::deserialize_flexible_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,9 +14,9 @@ pub struct SystemSettings {
|
||||
pub processing_model: String,
|
||||
pub embedding_model: String,
|
||||
pub embedding_dimensions: u32,
|
||||
/// Active embedding backend ("openai", "fastembed", "hashed"). Read-only, synced from config.
|
||||
/// Active embedding backend. Read-only for admin updates; synced from config at startup.
|
||||
#[serde(default)]
|
||||
pub embedding_backend: Option<String>,
|
||||
pub embedding_backend: Option<EmbeddingBackend>,
|
||||
pub query_system_prompt: String,
|
||||
pub ingestion_system_prompt: String,
|
||||
pub image_processing_model: String,
|
||||
@@ -23,6 +24,27 @@ pub struct SystemSettings {
|
||||
pub voice_processing_model: String,
|
||||
}
|
||||
|
||||
/// Partial update for singleton system settings without cloning unchanged fields.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SystemSettingsPatch {
|
||||
pub registrations_enabled: Option<bool>,
|
||||
pub require_email_verification: Option<bool>,
|
||||
pub query_model: Option<String>,
|
||||
pub processing_model: Option<String>,
|
||||
pub embedding_model: Option<String>,
|
||||
pub embedding_dimensions: Option<u32>,
|
||||
pub query_system_prompt: Option<String>,
|
||||
pub ingestion_system_prompt: Option<String>,
|
||||
pub image_processing_model: Option<String>,
|
||||
pub image_processing_prompt: Option<String>,
|
||||
pub voice_processing_model: Option<String>,
|
||||
}
|
||||
|
||||
enum UpdateMode {
|
||||
User,
|
||||
EmbeddingSync,
|
||||
}
|
||||
|
||||
impl StoredObject for SystemSettings {
|
||||
fn table_name() -> &'static str {
|
||||
"system_settings"
|
||||
@@ -33,29 +55,128 @@ impl StoredObject for SystemSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemSettingsPatch {
|
||||
pub fn apply_to(self, settings: &mut SystemSettings) {
|
||||
if let Some(value) = self.registrations_enabled {
|
||||
settings.registrations_enabled = value;
|
||||
}
|
||||
if let Some(value) = self.require_email_verification {
|
||||
settings.require_email_verification = value;
|
||||
}
|
||||
if let Some(value) = self.query_model {
|
||||
settings.query_model = value;
|
||||
}
|
||||
if let Some(value) = self.processing_model {
|
||||
settings.processing_model = value;
|
||||
}
|
||||
if let Some(value) = self.embedding_model {
|
||||
settings.embedding_model = value;
|
||||
}
|
||||
if let Some(value) = self.embedding_dimensions {
|
||||
settings.embedding_dimensions = value;
|
||||
}
|
||||
if let Some(value) = self.query_system_prompt {
|
||||
settings.query_system_prompt = value;
|
||||
}
|
||||
if let Some(value) = self.ingestion_system_prompt {
|
||||
settings.ingestion_system_prompt = value;
|
||||
}
|
||||
if let Some(value) = self.image_processing_model {
|
||||
settings.image_processing_model = value;
|
||||
}
|
||||
if let Some(value) = self.image_processing_prompt {
|
||||
settings.image_processing_prompt = value;
|
||||
}
|
||||
if let Some(value) = self.voice_processing_model {
|
||||
settings.voice_processing_model = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub async fn apply(self, db: &SurrealDbClient) -> Result<SystemSettings, AppError> {
|
||||
let mut current = SystemSettings::get_current(db).await?;
|
||||
self.apply_to(&mut current);
|
||||
SystemSettings::update(db, current).await
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemSettings {
|
||||
pub const RECORD_ID: &'static str = "current";
|
||||
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.embedding_dimensions == 0 {
|
||||
return Err(AppError::Validation(
|
||||
"embedding_dimensions must be greater than 0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let model_fields = [
|
||||
("query_model", &self.query_model),
|
||||
("processing_model", &self.processing_model),
|
||||
("embedding_model", &self.embedding_model),
|
||||
("image_processing_model", &self.image_processing_model),
|
||||
("voice_processing_model", &self.voice_processing_model),
|
||||
];
|
||||
for (name, value) in model_fields {
|
||||
if value.trim().is_empty() {
|
||||
return Err(AppError::Validation(format!("{name} must not be empty")));
|
||||
}
|
||||
}
|
||||
|
||||
let prompt_fields = [
|
||||
("query_system_prompt", &self.query_system_prompt),
|
||||
("ingestion_system_prompt", &self.ingestion_system_prompt),
|
||||
("image_processing_prompt", &self.image_processing_prompt),
|
||||
];
|
||||
for (name, value) in prompt_fields {
|
||||
if value.trim().is_empty() {
|
||||
return Err(AppError::Validation(format!("{name} must not be empty")));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub async fn get_current(db: &SurrealDbClient) -> Result<Self, AppError> {
|
||||
let settings: Option<Self> = db.get_item("current").await?;
|
||||
let settings: Option<Self> = db.get_item(Self::RECORD_ID).await?;
|
||||
settings.ok_or(AppError::NotFound("system settings not found".into()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub async fn update(db: &SurrealDbClient, changes: Self) -> Result<Self, AppError> {
|
||||
// We need to use a direct query for the update with MERGE
|
||||
Self::update_with_mode(db, changes, UpdateMode::User).await
|
||||
}
|
||||
|
||||
async fn update_with_mode(
|
||||
db: &SurrealDbClient,
|
||||
mut changes: Self,
|
||||
mode: UpdateMode,
|
||||
) -> Result<Self, AppError> {
|
||||
let current = Self::get_current(db).await?;
|
||||
if matches!(mode, UpdateMode::User) {
|
||||
changes.embedding_backend = current.embedding_backend;
|
||||
}
|
||||
changes.id = Self::RECORD_ID.to_string();
|
||||
changes.validate()?;
|
||||
|
||||
let updated: Option<Self> = db
|
||||
.client
|
||||
.query("UPDATE type::thing('system_settings', 'current') MERGE $changes RETURN AFTER")
|
||||
.query("UPDATE type::thing('system_settings', $id) MERGE $changes RETURN AFTER")
|
||||
.bind(("id", Self::RECORD_ID))
|
||||
.bind(("changes", changes))
|
||||
.await?
|
||||
.take(0)?;
|
||||
|
||||
updated.ok_or(AppError::Validation(
|
||||
"something went wrong updating the settings".into(),
|
||||
updated.ok_or(AppError::NotFound(
|
||||
"system settings record missing after update".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Syncs SystemSettings with the active embedding provider's properties.
|
||||
/// Updates embedding_backend, embedding_model, and embedding_dimensions if they differ.
|
||||
/// Returns true if any settings were changed.
|
||||
#[must_use]
|
||||
pub async fn sync_from_embedding_provider(
|
||||
db: &SurrealDbClient,
|
||||
provider: &crate::utils::embedding::EmbeddingProvider,
|
||||
@@ -63,23 +184,23 @@ impl SystemSettings {
|
||||
let mut settings = Self::get_current(db).await?;
|
||||
let mut needs_update = false;
|
||||
|
||||
let backend_label = provider.backend_label().to_string();
|
||||
let provider_dimensions = u32::try_from(provider.dimension()).unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
"Provider dimension {} exceeds u32 max; falling back to 0",
|
||||
let provider_backend = provider
|
||||
.backend_label()
|
||||
.parse::<EmbeddingBackend>()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
let provider_dimensions = u32::try_from(provider.dimension()).map_err(|_| {
|
||||
AppError::Validation(format!(
|
||||
"embedding provider dimension {} exceeds u32::MAX",
|
||||
provider.dimension()
|
||||
);
|
||||
0u32
|
||||
});
|
||||
))
|
||||
})?;
|
||||
let provider_model = provider.model_code();
|
||||
|
||||
// Sync backend label
|
||||
if settings.embedding_backend.as_deref() != Some(&backend_label) {
|
||||
settings.embedding_backend = Some(backend_label);
|
||||
if settings.embedding_backend != Some(provider_backend) {
|
||||
settings.embedding_backend = Some(provider_backend);
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
// Sync dimensions
|
||||
if settings.embedding_dimensions != provider_dimensions {
|
||||
tracing::info!(
|
||||
old_dimensions = settings.embedding_dimensions,
|
||||
@@ -90,7 +211,6 @@ impl SystemSettings {
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
// Sync model if provider has one
|
||||
if let Some(model) = provider_model {
|
||||
if settings.embedding_model != model {
|
||||
tracing::info!(
|
||||
@@ -104,7 +224,7 @@ impl SystemSettings {
|
||||
}
|
||||
|
||||
if needs_update {
|
||||
settings = Self::update(db, settings).await?;
|
||||
settings = Self::update_with_mode(db, settings, UpdateMode::EmbeddingSync).await?;
|
||||
}
|
||||
|
||||
Ok((settings, needs_update))
|
||||
@@ -225,15 +345,8 @@ mod tests {
|
||||
assert_eq!(settings.query_model, "gpt-4o-mini");
|
||||
assert_eq!(settings.processing_model, "gpt-4o-mini");
|
||||
assert_eq!(settings.image_processing_model, "gpt-4o-mini");
|
||||
// Dont test these for now, having a hard time getting the formatting exactly the same
|
||||
// assert_eq!(
|
||||
// settings.query_system_prompt,
|
||||
// crate::storage::types::system_prompts::DEFAULT_QUERY_SYSTEM_PROMPT
|
||||
// );
|
||||
// assert_eq!(
|
||||
// settings.ingestion_system_prompt,
|
||||
// crate::storage::types::system_prompts::DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT
|
||||
// );
|
||||
assert!(!settings.ingestion_system_prompt.contains("entity\"\n6."));
|
||||
assert!(settings.ingestion_system_prompt.contains("related entity."));
|
||||
|
||||
// Test idempotency - ensure calling it again doesn't change anything
|
||||
db.apply_migrations()
|
||||
@@ -298,7 +411,6 @@ mod tests {
|
||||
let mut updated_settings = SystemSettings::get_current(&db)
|
||||
.await
|
||||
.with_context(|| "get_current".to_string())?;
|
||||
updated_settings.id = "current".to_string();
|
||||
updated_settings.registrations_enabled = false;
|
||||
updated_settings.require_email_verification = true;
|
||||
updated_settings.query_model = "gpt-4".to_string();
|
||||
@@ -347,6 +459,46 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_rejects_zero_embedding_dimensions() -> anyhow::Result<()> {
|
||||
let db = SurrealDbClient::memory("test_ns", &Uuid::new_v4().to_string())
|
||||
.await
|
||||
.with_context(|| "Failed to start in-memory surrealdb".to_string())?;
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.with_context(|| "Failed to apply migrations".to_string())?;
|
||||
|
||||
let mut invalid_settings = SystemSettings::get_current(&db)
|
||||
.await
|
||||
.with_context(|| "Failed to get system settings".to_string())?;
|
||||
invalid_settings.embedding_dimensions = 0;
|
||||
|
||||
let result = SystemSettings::update(&db, invalid_settings).await;
|
||||
assert!(matches!(result, Err(AppError::Validation(_))));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_patch_updates_without_cloning_full_settings() -> anyhow::Result<()> {
|
||||
let db = SurrealDbClient::memory("test_ns", &Uuid::new_v4().to_string())
|
||||
.await
|
||||
.with_context(|| "Failed to start in-memory surrealdb".to_string())?;
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.with_context(|| "Failed to apply migrations".to_string())?;
|
||||
|
||||
let updated = SystemSettingsPatch {
|
||||
registrations_enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
.apply(&db)
|
||||
.await
|
||||
.with_context(|| "Failed to patch settings".to_string())?;
|
||||
|
||||
assert!(!updated.registrations_enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migration_after_changing_embedding_length() -> anyhow::Result<()> {
|
||||
let db = SurrealDbClient::memory("test", &Uuid::new_v4().to_string())
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::{
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_openai::{types::CreateEmbeddingRequestArgs, Client};
|
||||
use fastembed::{EmbeddingModel, ModelTrait, TextEmbedding, TextInitOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -26,7 +27,8 @@ pub struct ParseEmbeddingBackendError {
|
||||
|
||||
/// Supported embedding backends.
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EmbeddingBackend {
|
||||
#[default]
|
||||
OpenAI,
|
||||
@@ -34,6 +36,17 @@ pub enum EmbeddingBackend {
|
||||
Hashed,
|
||||
}
|
||||
|
||||
impl EmbeddingBackend {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAI => "openai",
|
||||
Self::FastEmbed => "fastembed",
|
||||
Self::Hashed => "hashed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for EmbeddingBackend {
|
||||
type Err = ParseEmbeddingBackendError;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user