use crate::AgentConfig;
use crate::tenant::TenantContext;
use greentic_types::{EnvId, TenantCtx, TenantId};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KnowledgeChunk {
pub doc_id: String,
pub chunk_index: usize,
pub text: String,
#[serde(default)]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct IngestOutcome {
pub chunk_ids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KnowledgeQuery {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RetrievedChunk {
pub text: String,
pub score: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chunk_index: Option<usize>,
#[serde(default)]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Error)]
pub enum KnowledgeError {
#[error("knowledge backend error: {0}")]
Backend(String),
#[error("invalid tenant: {0}")]
InvalidTenant(String),
#[error("knowledge provider not configured")]
NotConfigured,
}
pub type KnowledgeResult<T> = Result<T, KnowledgeError>;
#[async_trait::async_trait]
pub trait Knowledge: Send + Sync {
async fn ingest(
&self,
tenant: &TenantCtx,
chunks: Vec<KnowledgeChunk>,
) -> KnowledgeResult<IngestOutcome>;
async fn search(
&self,
tenant: &TenantCtx,
query: KnowledgeQuery,
) -> KnowledgeResult<Vec<RetrievedChunk>>;
}
pub(crate) fn to_types_tenant(ctx: &TenantContext) -> Result<TenantCtx, KnowledgeError> {
let env = EnvId::try_from(ctx.env_id.as_str())
.map_err(|e| KnowledgeError::InvalidTenant(format!("env_id '{}': {e}", ctx.env_id)))?;
let tenant = TenantId::try_from(ctx.tenant_id.as_str()).map_err(|e| {
KnowledgeError::InvalidTenant(format!("tenant_id '{}': {e}", ctx.tenant_id))
})?;
Ok(TenantCtx::new(env, tenant))
}
pub(crate) fn auto_top_k(config: &AgentConfig) -> usize {
config
.knowledge
.as_ref()
.map(|k| k.top_k)
.unwrap_or_else(crate::config::default_knowledge_top_k)
.max(1)
}
pub(crate) fn knowledge_active(has_provider: bool, config: &AgentConfig) -> bool {
has_provider
&& config
.knowledge
.as_ref()
.and_then(|k| k.knowledge.as_ref())
.is_some()
}
pub(crate) fn augment_system_prompt(base: &str, chunks: &[RetrievedChunk]) -> String {
if chunks.is_empty() {
return base.to_string();
}
let mut out = String::with_capacity(base.len() + 128 * chunks.len());
out.push_str(base);
out.push_str("\n\n<knowledge>\nRelevant passages retrieved from the agent's knowledge base:\n");
for c in chunks {
out.push_str("- ");
out.push_str(c.text.trim());
out.push('\n');
}
out.push_str("</knowledge>");
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn to_types_tenant_maps_valid_ids() {
let ctx = TenantContext::new("acme", "dev");
let resolved = to_types_tenant(&ctx).expect("valid tenant converts");
assert_eq!(resolved.tenant.as_str(), "acme");
assert_eq!(resolved.env.as_str(), "dev");
}
#[test]
fn to_types_tenant_rejects_empty_tenant() {
let ctx = TenantContext::new("", "dev");
let err = to_types_tenant(&ctx).expect_err("empty tenant id is invalid");
assert!(matches!(err, KnowledgeError::InvalidTenant(_)));
}
fn cfg_with_knowledge(top_k: Option<usize>, with_binding: bool) -> AgentConfig {
use crate::config::{KnowledgeSettings, MemoryProviderRef};
let binding = with_binding.then(|| MemoryProviderRef {
provider: "provider.knowledge.chronicle".into(),
capability: "cap://dw.knowledge".into(),
params: serde_json::Map::new(),
credential_ref: None,
});
AgentConfig {
agent_id: "a".into(),
system_prompt: "s".into(),
tools: vec![],
llm: crate::LlmProviderRef {
provider: "m".into(),
model: "m".into(),
credential_ref: None,
},
limits: crate::AgentLimits::default(),
memory: None,
knowledge: Some(KnowledgeSettings {
knowledge: binding,
embedding: None,
top_k: top_k.unwrap_or_else(crate::config::default_knowledge_top_k),
}),
guardrails: vec![],
}
}
#[test]
fn knowledge_active_requires_provider_and_enabled_binding() {
let cfg = cfg_with_knowledge(None, true);
assert!(knowledge_active(true, &cfg));
assert!(!knowledge_active(false, &cfg));
let cfg_no_binding = cfg_with_knowledge(None, false);
assert!(!knowledge_active(true, &cfg_no_binding));
let mut bare = cfg_with_knowledge(None, true);
bare.knowledge = None;
assert!(!knowledge_active(true, &bare));
}
#[test]
fn auto_top_k_uses_config_then_default_clamped() {
assert_eq!(auto_top_k(&cfg_with_knowledge(Some(3), true)), 3);
assert_eq!(auto_top_k(&cfg_with_knowledge(None, true)), 5);
assert_eq!(auto_top_k(&cfg_with_knowledge(Some(0), true)), 1);
let mut bare = cfg_with_knowledge(None, true);
bare.knowledge = None;
assert_eq!(auto_top_k(&bare), 5);
}
fn chunk(text: &str, score: f64) -> RetrievedChunk {
RetrievedChunk {
text: text.into(),
score,
doc_id: None,
chunk_index: None,
metadata: serde_json::Map::new(),
}
}
#[test]
fn augment_with_chunks_wraps_a_block() {
let chunks = vec![
chunk("Refunds are processed within 5 business days.", 0.9),
chunk("Premium plans include priority support.", 0.7),
];
let out = augment_system_prompt("base prompt", &chunks);
assert!(out.starts_with("base prompt"));
assert!(out.contains("<knowledge>"));
assert!(out.contains("</knowledge>"));
assert!(out.contains("Refunds are processed within 5 business days."));
assert!(out.contains("Premium plans include priority support."));
}
#[test]
fn augment_with_no_chunks_returns_base_unchanged() {
let out = augment_system_prompt("base prompt", &[]);
assert_eq!(out, "base prompt");
}
struct StubKnowledge;
#[async_trait::async_trait]
impl Knowledge for StubKnowledge {
async fn ingest(
&self,
_tenant: &TenantCtx,
chunks: Vec<KnowledgeChunk>,
) -> KnowledgeResult<IngestOutcome> {
Ok(IngestOutcome {
chunk_ids: chunks
.iter()
.map(|c| format!("{}#{}", c.doc_id, c.chunk_index))
.collect(),
})
}
async fn search(
&self,
_tenant: &TenantCtx,
query: KnowledgeQuery,
) -> KnowledgeResult<Vec<RetrievedChunk>> {
Ok(vec![chunk(&format!("retrieved for: {}", query.query), 1.0)])
}
}
#[tokio::test]
async fn trait_object_drives_ingest_and_search_through_converted_tenant() {
let kb: Arc<dyn Knowledge> = Arc::new(StubKnowledge);
let ctx = to_types_tenant(&TenantContext::new("acme", "dev")).unwrap();
let outcome = kb
.ingest(
&ctx,
vec![KnowledgeChunk {
doc_id: "faq".into(),
chunk_index: 0,
text: "Refunds within 5 days.".into(),
metadata: serde_json::Map::new(),
}],
)
.await
.unwrap();
assert_eq!(outcome.chunk_ids, vec!["faq#0".to_string()]);
let hits = kb
.search(
&ctx,
KnowledgeQuery {
query: "refund policy".into(),
limit: Some(3),
},
)
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].text, "retrieved for: refund policy");
}
}