Skip to main content

Content Data Provider

Implement ContentDataProvider::enrich_content() to add computed fields, related content, and database lookups to content items before page data runs.

ContentDataProvider enriches content items during contents_to_json(), before PageDataProviders run. Use this to add computed fields, related content, or database lookups.

When It Runs

Database Query
     |
=======================================
ContentDataProvider::enrich_content()  <- You are here
=======================================
     |
PageDataProvider::provide_page_data()
     |
ComponentRenderer::render()
     |
Template rendering

The Trait

#[async_trait]
pub trait ContentDataProvider: Send + Sync {
    fn provider_id(&self) -> &'static str;

    fn applies_to_sources(&self) -> Vec<String> {
        vec![]
    }

    fn priority(&self) -> u32 {
        100
    }

    async fn enrich_content(
        &self,
        ctx: &ContentDataContext<'_>,
        item: &mut Value,
    ) -> ProviderResult<()>;
}

ProviderResult<T> is Result<T, ProviderError> from systemprompt::traits. Map internal failures to ProviderError::Internal(String).

ContentDataContext

impl<'a> ContentDataContext<'a> {
    pub fn content_id(&self) -> &str;
    pub fn source_name(&self) -> &str;
    pub fn db_pool<T: 'static>(&self) -> Option<&T>;
}

The generator passes the database handle as Arc<Database>, so downcast with ctx.db_pool::<Arc<Database>>() and call .pool() on it to get the sqlx::PgPool.

Basic Implementation

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::{json, Value};
use systemprompt::database::Database;
use systemprompt::extension::prelude::{ContentDataContext, ContentDataProvider};
use systemprompt::traits::{ProviderError, ProviderResult};

pub struct DocsContentDataProvider;

#[async_trait]
impl ContentDataProvider for DocsContentDataProvider {
    fn provider_id(&self) -> &'static str {
        "docs-content-enricher"
    }

    fn applies_to_sources(&self) -> Vec<String> {
        vec!["documentation".to_string()]
    }

    fn priority(&self) -> u32 {
        100
    }

    async fn enrich_content(
        &self,
        ctx: &ContentDataContext<'_>,
        item: &mut Value,
    ) -> ProviderResult<()> {
        let slug = item.get("slug").and_then(|v| v.as_str()).unwrap_or("").to_string();

        // Add computed field
        let reading_time = self.calculate_reading_time(item);
        if let Some(obj) = item.as_object_mut() {
            obj.insert("reading_time".to_string(), json!(reading_time));
        }

        // Add children for index pages
        if item.get("kind").and_then(|v| v.as_str()) == Some("docs-index") {
            if let Some(db) = ctx.db_pool::<Arc<Database>>() {
                let pool = db
                    .pool()
                    .ok_or_else(|| ProviderError::Internal("pool not initialized".into()))?;
                let children = self.fetch_children(&pool, ctx.source_name(), &slug).await?;
                if let Some(obj) = item.as_object_mut() {
                    obj.insert("children".to_string(), json!(children));
                }
            }
        }

        Ok(())
    }
}

impl DocsContentDataProvider {
    fn calculate_reading_time(&self, item: &Value) -> u32 {
        let body = item.get("body").and_then(|v| v.as_str()).unwrap_or("");
        let words = body.split_whitespace().count();
        ((words as f32) / 200.0).ceil() as u32
    }

    async fn fetch_children(
        &self,
        pool: &sqlx::PgPool,
        source: &str,
        parent_slug: &str,
    ) -> ProviderResult<Vec<Value>> {
        let children = sqlx::query!(
            r#"SELECT slug, title, description
               FROM markdown_content
               WHERE source_id = $1 AND slug LIKE $2
               ORDER BY slug"#,
            source,
            format!("{}/%", parent_slug)
        )
        .fetch_all(pool)
        .await
        .map_err(|e| ProviderError::Internal(e.to_string()))?;

        Ok(children.into_iter().map(|c| json!({
            "slug": c.slug,
            "title": c.title,
            "description": c.description,
        })).collect())
    }
}

Targeting Sources

Use applies_to_sources() to run only for specific content sources:

fn applies_to_sources(&self) -> Vec<String> {
    vec!["documentation".to_string(), "blog".to_string()]
}

Return an empty vector to run for ALL sources.

Registration

impl Extension for WebExtension {
    fn content_data_providers(&self) -> Vec<Arc<dyn ContentDataProvider>> {
        vec![
            Arc::new(DocsContentDataProvider),
            Arc::new(RelatedPostsProvider::new(self.pool.clone())),
        ]
    }
}

Common Patterns

async fn enrich_content(&self, ctx: &ContentDataContext<'_>, item: &mut Value) -> ProviderResult<()> {
    let tags = item.get("tags").and_then(|v| v.as_array()).cloned();
    if let (Some(tags), Some(db)) = (tags, ctx.db_pool::<Arc<Database>>()) {
        let related = self.find_by_tags(db, &tags).await?;
        if let Some(obj) = item.as_object_mut() {
            obj.insert("related_posts".to_string(), json!(related));
        }
    }
    Ok(())
}
async fn enrich_content(&self, ctx: &ContentDataContext<'_>, item: &mut Value) -> ProviderResult<()> {
    let slug = item.get("slug").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let breadcrumbs = self.build_breadcrumbs(&slug);
    if let Some(obj) = item.as_object_mut() {
        obj.insert("breadcrumbs".to_string(), json!(breadcrumbs));
    }
    Ok(())
}