use std::collections::{BTreeMap, BTreeSet};
use crate::{AgentId, ConversationId, KitError, WorkspaceId};
const RETRIEVAL_SCOPE_ORDER: [KnowledgeScope; 4] = [
KnowledgeScope::Conversation,
KnowledgeScope::Workspace,
KnowledgeScope::Agent,
KnowledgeScope::Database,
];
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "scope", rename_all = "snake_case")]
pub(crate) enum KnowledgeOwnerId {
Database,
Agent {
agent_id: AgentId,
},
Workspace {
agent_id: AgentId,
workspace_id: WorkspaceId,
},
Conversation {
agent_id: AgentId,
workspace_id: WorkspaceId,
conversation_id: ConversationId,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct KnowledgeBudget {
chunks: usize,
bytes: usize,
}
#[derive(Clone, Debug)]
pub(crate) struct KnowledgeRetrievalRequest {
query: String,
owner: KnowledgeOwnerId,
policy: KnowledgePolicy,
budget: KnowledgeBudget,
}
#[derive(Clone, Debug)]
pub(crate) struct KnowledgeContext {
snippets: Vec<KnowledgeSnippet>,
}
#[derive(Clone, Debug)]
pub(crate) struct KnowledgeSnippet {
source_id: String,
title: String,
text: String,
owner: KnowledgeOwnerId,
relevance_score: Option<f64>,
provenance: KnowledgeProvenance,
metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct KnowledgeProvenance {
locator: Option<String>,
metadata: BTreeMap<String, serde_json::Value>,
}
#[derive(
Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgeScope {
Database,
Agent,
Workspace,
Conversation,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum KnowledgePolicy {
Default,
Disabled,
Only(
Vec<KnowledgeScope>,
),
}
impl KnowledgePolicy {
pub fn only(scopes: Vec<KnowledgeScope>) -> Result<Self, KitError> {
let policy = Self::Only(scopes);
policy.validate()?;
Ok(policy)
}
pub(crate) fn validate(&self) -> Result<(), KitError> {
let Self::Only(scopes) = self else {
return Ok(());
};
if scopes.is_empty() {
return Err(KitError::invalid_input(
"knowledge scope selection must not be empty",
));
}
let mut sorted = scopes.clone();
sorted.sort();
sorted.dedup();
if sorted.len() != scopes.len() {
return Err(KitError::invalid_input(
"knowledge scope selection must not contain duplicates",
));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum KnowledgeSource {
Text {
source_id: String,
title: String,
text: String,
},
Bytes {
source_id: String,
title: String,
bytes: Vec<u8>,
mime_type: String,
},
}
impl KnowledgeSource {
pub fn text(
source_id: impl Into<String>,
title: impl Into<String>,
text: impl Into<String>,
) -> Result<Self, KitError> {
let source = Self::Text {
source_id: source_id.into(),
title: title.into(),
text: text.into(),
};
source.validate()?;
Ok(source)
}
pub fn bytes(
source_id: impl Into<String>,
title: impl Into<String>,
bytes: Vec<u8>,
mime_type: impl Into<String>,
) -> Result<Self, KitError> {
let source = Self::Bytes {
source_id: source_id.into(),
title: title.into(),
bytes,
mime_type: mime_type.into(),
};
source.validate()?;
Ok(source)
}
pub fn source_id(&self) -> &str {
match self {
Self::Text { source_id, .. } | Self::Bytes { source_id, .. } => source_id,
}
}
pub fn title(&self) -> &str {
match self {
Self::Text { title, .. } | Self::Bytes { title, .. } => title,
}
}
pub(crate) fn validate(&self) -> Result<(), KitError> {
let (source_id, title) = match self {
Self::Text {
source_id,
title,
text,
} => {
if text.is_empty() {
return Err(KitError::invalid_input("knowledge text must not be empty"));
}
(source_id, title)
}
Self::Bytes {
source_id,
title,
bytes,
mime_type,
} => {
if bytes.is_empty() {
return Err(KitError::invalid_input("knowledge bytes must not be empty"));
}
Self::validate_text("knowledge MIME type", mime_type)?;
(source_id, title)
}
};
Self::validate_text("knowledge source id", source_id)?;
Self::validate_text("knowledge source title", title)
}
fn validate_text(label: &str, value: &str) -> Result<(), KitError> {
if value.is_empty() || value.trim() != value {
return Err(KitError::invalid_input(format!(
"{label} must be nonempty and unpadded"
)));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IngestKnowledgeRequest {
sources: Vec<KnowledgeSource>,
scope: Option<KnowledgeScope>,
}
impl IngestKnowledgeRequest {
pub fn new(sources: Vec<KnowledgeSource>) -> Result<Self, KitError> {
let request = Self {
sources,
scope: None,
};
request.validate()?;
Ok(request)
}
#[must_use]
pub fn with_scope(mut self, scope: KnowledgeScope) -> Self {
self.scope = Some(scope);
self
}
pub fn sources(&self) -> &[KnowledgeSource] {
&self.sources
}
#[must_use]
pub fn scope(&self) -> Option<KnowledgeScope> {
self.scope
}
#[must_use]
pub fn into_parts(self) -> (Vec<KnowledgeSource>, Option<KnowledgeScope>) {
(self.sources, self.scope)
}
pub(crate) fn validate(&self) -> Result<(), KitError> {
let mut ids = std::collections::BTreeSet::new();
for source in &self.sources {
source.validate()?;
if !ids.insert(source.source_id()) {
return Err(KitError::invalid_input(
"knowledge source ids must be unique within one request",
));
}
}
Ok(())
}
}
impl KnowledgeOwnerId {
fn scope(&self) -> KnowledgeScope {
match self {
Self::Database => KnowledgeScope::Database,
Self::Agent { .. } => KnowledgeScope::Agent,
Self::Workspace { .. } => KnowledgeScope::Workspace,
Self::Conversation { .. } => KnowledgeScope::Conversation,
}
}
fn owner_for_scope(&self, scope: KnowledgeScope) -> Option<Self> {
match (self, scope) {
(Self::Database, KnowledgeScope::Database)
| (Self::Agent { .. }, KnowledgeScope::Database)
| (Self::Workspace { .. }, KnowledgeScope::Database)
| (Self::Conversation { .. }, KnowledgeScope::Database) => Some(Self::Database),
(Self::Agent { agent_id }, KnowledgeScope::Agent)
| (Self::Workspace { agent_id, .. }, KnowledgeScope::Agent)
| (Self::Conversation { agent_id, .. }, KnowledgeScope::Agent) => Some(Self::Agent {
agent_id: agent_id.clone(),
}),
(
Self::Workspace {
agent_id,
workspace_id,
},
KnowledgeScope::Workspace,
)
| (
Self::Conversation {
agent_id,
workspace_id,
..
},
KnowledgeScope::Workspace,
) => Some(Self::Workspace {
agent_id: agent_id.clone(),
workspace_id: workspace_id.clone(),
}),
(
Self::Conversation {
agent_id,
workspace_id,
conversation_id,
},
KnowledgeScope::Conversation,
) => Some(Self::Conversation {
agent_id: agent_id.clone(),
workspace_id: workspace_id.clone(),
conversation_id: conversation_id.clone(),
}),
_ => None,
}
}
pub(crate) fn ingestion_owner(
&self,
selected: Option<KnowledgeScope>,
) -> Result<Self, KitError> {
self.owner_for_scope(selected.unwrap_or_else(|| self.scope()))
.ok_or_else(|| {
KitError::invalid_input(
"selected knowledge scope is not applicable to this receiver",
)
})
}
pub(crate) fn retrieval_owners(&self, policy: &KnowledgePolicy) -> Result<Vec<Self>, KitError> {
policy.validate()?;
let selected = match policy {
KnowledgePolicy::Default => None,
KnowledgePolicy::Disabled => return Ok(Vec::new()),
KnowledgePolicy::Only(scopes) => {
for scope in scopes {
if self.owner_for_scope(*scope).is_none() {
return Err(KitError::invalid_input(
"selected knowledge scope is not applicable to this owner",
));
}
}
Some(scopes.iter().copied().collect::<BTreeSet<_>>())
}
};
Ok(RETRIEVAL_SCOPE_ORDER
.into_iter()
.filter(|scope| {
selected
.as_ref()
.is_none_or(|selected| selected.contains(scope))
})
.filter_map(|scope| self.owner_for_scope(scope))
.collect())
}
}
impl KnowledgeBudget {
pub(crate) fn new(chunks: usize, bytes: usize) -> Result<Self, KitError> {
if chunks == 0 || bytes == 0 {
return Err(KitError::invalid_input(
"knowledge chunk and byte budgets must be positive",
));
}
Ok(Self { chunks, bytes })
}
#[must_use]
pub(crate) fn chunks(&self) -> usize {
self.chunks
}
#[must_use]
pub(crate) fn bytes(&self) -> usize {
self.bytes
}
}
impl KnowledgeRetrievalRequest {
pub(crate) fn new(
query: String,
owner: KnowledgeOwnerId,
policy: KnowledgePolicy,
budget: KnowledgeBudget,
) -> Result<Self, KitError> {
let request = Self {
query,
owner,
policy,
budget,
};
request.validate()?;
Ok(request)
}
pub(crate) fn query(&self) -> &str {
&self.query
}
pub(crate) fn owner(&self) -> &KnowledgeOwnerId {
&self.owner
}
pub(crate) fn policy(&self) -> &KnowledgePolicy {
&self.policy
}
#[must_use]
pub(crate) fn budget(&self) -> KnowledgeBudget {
self.budget
}
pub(crate) fn validate(&self) -> Result<(), KitError> {
if self.query.is_empty() || self.query.trim() != self.query {
return Err(KitError::invalid_input(
"knowledge query must be nonempty and unpadded",
));
}
self.owner.retrieval_owners(&self.policy).map(|_| ())
}
pub(crate) fn validate_context(&self, context: &KnowledgeContext) -> Result<(), KitError> {
self.validate()?;
context
.validate()
.map_err(|_| KitError::invalid_state("knowledge backend returned malformed context"))?;
if context.snippets.len() > self.budget.chunks {
return Err(KitError::invalid_state(
"knowledge backend exceeded the requested chunk budget",
));
}
let allowed = self.owner.retrieval_owners(&self.policy)?;
let mut previous_scope = None;
let mut text_bytes = 0_usize;
for snippet in &context.snippets {
let scope = allowed
.iter()
.position(|owner| owner == &snippet.owner)
.ok_or_else(|| {
KitError::invalid_state(
"knowledge backend returned an owner outside the requested lineage",
)
})?;
if previous_scope.is_some_and(|previous| scope < previous) {
return Err(KitError::invalid_state(
"knowledge backend returned snippets outside policy precedence",
));
}
previous_scope = Some(scope);
text_bytes = text_bytes.checked_add(snippet.text.len()).ok_or_else(|| {
KitError::invalid_state("knowledge backend text-byte count overflowed")
})?;
if text_bytes > self.budget.bytes {
return Err(KitError::invalid_state(
"knowledge backend exceeded the requested byte budget",
));
}
}
Ok(())
}
}
impl KnowledgeContext {
pub(crate) fn new(snippets: Vec<KnowledgeSnippet>) -> Result<Self, KitError> {
let context = Self { snippets };
context.validate()?;
Ok(context)
}
pub(crate) fn snippets(&self) -> &[KnowledgeSnippet] {
&self.snippets
}
fn validate(&self) -> Result<(), KitError> {
for snippet in &self.snippets {
snippet.validate()?;
}
Ok(())
}
}
impl KnowledgeSnippet {
pub(crate) fn new(
source_id: String,
title: String,
text: String,
owner: KnowledgeOwnerId,
relevance_score: Option<f64>,
provenance: KnowledgeProvenance,
metadata: BTreeMap<String, serde_json::Value>,
) -> Result<Self, KitError> {
let snippet = Self {
source_id,
title,
text,
owner,
relevance_score,
provenance,
metadata,
};
snippet.validate()?;
Ok(snippet)
}
pub(crate) fn source_id(&self) -> &str {
&self.source_id
}
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn text(&self) -> &str {
&self.text
}
fn validate(&self) -> Result<(), KitError> {
for (label, value) in [
("knowledge source id", self.source_id.as_str()),
("knowledge source title", self.title.as_str()),
] {
if value.is_empty() || value.trim() != value {
return Err(KitError::invalid_input(format!(
"{label} must be nonempty and unpadded"
)));
}
}
if self.text.trim().is_empty() {
return Err(KitError::invalid_input(
"knowledge snippet text must be nonblank",
));
}
if self
.relevance_score
.is_some_and(|score| !score.is_finite() || score < 0.0)
{
return Err(KitError::invalid_input(
"knowledge relevance score must be finite and nonnegative",
));
}
self.provenance.validate()?;
if self
.metadata
.keys()
.any(|key| key.is_empty() || key.trim() != key)
{
return Err(KitError::invalid_input(
"knowledge metadata keys must be nonempty and unpadded",
));
}
Ok(())
}
}
impl KnowledgeProvenance {
fn validate(&self) -> Result<(), KitError> {
if self
.locator
.as_ref()
.is_some_and(|locator| locator.is_empty() || locator.trim() != locator)
{
return Err(KitError::invalid_input(
"knowledge provenance locator must be nonempty and unpadded",
));
}
if self
.metadata
.keys()
.any(|key| key.is_empty() || key.trim() != key)
{
return Err(KitError::invalid_input(
"knowledge provenance metadata keys must be nonempty and unpadded",
));
}
Ok(())
}
}