use serde_json::Value;
use std::sync::Arc;
use paladin_core::platform::container::content::ContentItem;
use paladin_core::platform::container::prompt::PromptItem;
use crate::services::content_analysis_service::ContentAnalysisService;
use paladin_llm::llm_analysis_service::{LlmAnalysisConfig, LlmAnalysisInput, LlmAnalysisService};
#[derive(Debug, Clone)]
pub struct LlmContentAnalysisInput {
pub prompt: PromptItem,
pub content: ContentItem,
}
#[derive(Debug, Clone)]
pub struct LlmContentAnalysisConfig {
pub llm_config: LlmAnalysisConfig,
pub include_content_metadata: bool,
pub max_content_length: Option<usize>,
}
impl Default for LlmContentAnalysisConfig {
fn default() -> Self {
Self {
llm_config: LlmAnalysisConfig {
model: "gpt-3.5-turbo".to_string(),
max_retries: 3,
timeout_seconds: 30,
enable_streaming: false,
},
include_content_metadata: true,
max_content_length: Some(10000),
}
}
}
pub struct LlmContentAnalyzer {
llm_service: Arc<LlmAnalysisService>,
}
impl LlmContentAnalyzer {
pub fn new(llm_service: Arc<LlmAnalysisService>) -> Self {
Self { llm_service }
}
pub async fn analyze_with_prompt_async(
&self,
input: &LlmContentAnalysisInput,
config: &LlmContentAnalysisConfig,
) -> Result<Value, String> {
self.validate_input(input, config)?;
let llm_input = LlmAnalysisInput {
prompt: input.prompt.clone(),
content_attachments: vec![input.content.clone()],
};
let result = self
.llm_service
.analyze_async(&llm_input, &config.llm_config)
.await
.map_err(|e| format!("LLM analysis failed: {:?}", e))?;
self.process_analysis_result(&result.result.content, &input.content, config)
}
pub fn analyze_with_prompt(
&self,
_input: &LlmContentAnalysisInput,
_config: &LlmContentAnalysisConfig,
) -> Result<Value, String> {
Err("Sync analysis not supported. Please use analyze_with_prompt_async for proper async handling.".to_string())
}
fn validate_input(
&self,
input: &LlmContentAnalysisInput,
config: &LlmContentAnalysisConfig,
) -> Result<(), String> {
match input.prompt.prompt_type() {
paladin_core::platform::container::prompt::PromptType::Text(text_prompt) => {
if text_prompt.content.trim().is_empty() {
return Err("Prompt content cannot be empty".to_string());
}
}
_ => {
return Err("Only text prompts are supported for content analysis".to_string());
}
}
if let Some(max_length) = config.max_content_length {
let content_text = self.extract_content_text(&input.content);
if content_text.len() > max_length {
return Err(format!(
"Content length ({}) exceeds maximum allowed length ({})",
content_text.len(),
max_length
));
}
}
Ok(())
}
fn extract_content_text(&self, content: &ContentItem) -> String {
match content.content() {
paladin_core::platform::container::content::ContentType::Text(text) => {
text.content.as_deref().unwrap_or("").to_string()
}
paladin_core::platform::container::content::ContentType::Video(_) => {
format!(
"Video content: {}",
content.title().unwrap_or(&"Untitled".to_string())
)
}
paladin_core::platform::container::content::ContentType::Audio(_) => {
format!(
"Audio content: {}",
content.title().unwrap_or(&"Untitled".to_string())
)
}
paladin_core::platform::container::content::ContentType::Image(_) => {
format!(
"Image content: {}",
content.title().unwrap_or(&"Untitled".to_string())
)
}
}
}
fn process_analysis_result(
&self,
raw_result: &str,
content: &ContentItem,
config: &LlmContentAnalysisConfig,
) -> Result<Value, String> {
let mut result = match serde_json::from_str::<Value>(raw_result) {
Ok(json_value) => json_value,
Err(_) => {
serde_json::json!({
"analysis": raw_result,
"format": "raw_text"
})
}
};
if config.include_content_metadata
&& let Value::Object(ref mut map) = result
{
let metadata = serde_json::json!({
"content_id": content.uuid(),
"content_title": content.title().unwrap_or(&"Untitled".to_string()),
"content_type": match content.content() {
paladin_core::platform::container::content::ContentType::Text(_) => "text",
paladin_core::platform::container::content::ContentType::Video(_) => "video",
paladin_core::platform::container::content::ContentType::Audio(_) => "audio",
paladin_core::platform::container::content::ContentType::Image(_) => "image",
},
"analysis_timestamp": chrono::Utc::now().to_rfc3339(),
});
map.insert("content_metadata".to_string(), metadata);
}
Ok(result)
}
}
pub struct DefaultPromptContentAnalyzer {
analyzer: LlmContentAnalyzer,
default_prompt: PromptItem,
config: LlmContentAnalysisConfig,
}
impl DefaultPromptContentAnalyzer {
pub fn new(
llm_service: Arc<LlmAnalysisService>,
default_prompt: PromptItem,
config: LlmContentAnalysisConfig,
) -> Self {
Self {
analyzer: LlmContentAnalyzer::new(llm_service),
default_prompt,
config,
}
}
pub fn create_default_analysis_prompt() -> Result<PromptItem, String> {
use paladin_core::platform::container::prompt::{PromptRole, PromptType, TextPrompt};
let prompt_text = r#"Please analyze the following content and provide insights in JSON format:
{
"main_topics": ["topic1", "topic2"],
"key_information": ["fact1", "fact2"],
"sentiment": "positive/negative/neutral",
"tone": "formal/informal/technical/etc",
"quality_score": 0.8,
"summary": "Brief summary of the content",
"suggestions": ["improvement1", "improvement2"]
}
Analyze the attached content according to these categories."#;
let text_prompt = TextPrompt {
content: prompt_text.to_string(),
role: PromptRole::User,
};
PromptItem::new_with_title(
PromptType::Text(text_prompt),
"Default Content Analysis Prompt".to_string(),
)
.map_err(|e| format!("Failed to create default prompt: {:?}", e))
}
}
impl ContentAnalysisService for DefaultPromptContentAnalyzer {
fn analyze_content(&self, content: &ContentItem) -> Result<Value, String> {
let input = LlmContentAnalysisInput {
prompt: self.default_prompt.clone(),
content: content.clone(),
};
self.analyzer.analyze_with_prompt(&input, &self.config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use paladin_core::platform::container::content::{ContentType, TextContent};
use paladin_core::platform::container::prompt::{PromptRole, PromptType, TextPrompt};
use paladin_ports::output::llm_port::{
FinishReason, LlmError, LlmPort, LlmRequest, LlmResponse, TokenUsage,
};
use std::collections::HashMap;
use uuid::Uuid;
struct MockLlmPort;
#[async_trait]
impl LlmPort for MockLlmPort {
async fn generate(&self, _request: LlmRequest) -> Result<LlmResponse, LlmError> {
Ok(LlmResponse {
id: Uuid::new_v4(),
request_id: Uuid::new_v4(),
model: "test-model".to_string(),
content: r#"{"topics": ["test"], "sentiment": "positive", "quality_score": 0.8}"#
.to_string(),
finish_reason: FinishReason::Stop,
usage: TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
created_at: Utc::now(),
metadata: HashMap::new(),
function_call: None,
})
}
async fn generate_stream(
&self,
_request: LlmRequest,
) -> Result<
Box<
dyn futures::Stream<
Item = Result<paladin_ports::output::llm_port::StreamingResponse, LlmError>,
> + Send,
>,
LlmError,
> {
todo!()
}
async fn validate_model(&self, _model: &str) -> Result<bool, LlmError> {
Ok(true)
}
async fn get_available_models(&self) -> Result<Vec<String>, LlmError> {
Ok(vec!["test-model".to_string()])
}
fn get_provider_name(&self) -> &'static str {
"test"
}
fn get_capabilities(&self) -> paladin_ports::output::llm_port::ProviderCapabilities {
paladin_ports::output::llm_port::ProviderCapabilities::default()
}
}
#[tokio::test] async fn test_llm_content_analyzer_with_prompt() {
let mock_port = Arc::new(MockLlmPort);
let llm_service = Arc::new(LlmAnalysisService::new(mock_port));
let analyzer = LlmContentAnalyzer::new(llm_service);
let text_content =
TextContent::new(None, Some("This is test content for analysis".to_string()))
.expect("Failed to create text content");
let content = ContentItem::new_with_title(
ContentType::Text(text_content),
"Test Content".to_string(),
)
.expect("Failed to create content item");
let text_prompt = TextPrompt {
content: "Analyze this content and return insights in JSON format.".to_string(),
role: PromptRole::User,
};
let prompt = PromptItem::new_with_title(
PromptType::Text(text_prompt),
"Test Analysis Prompt".to_string(),
)
.expect("Failed to create prompt");
let input = LlmContentAnalysisInput { prompt, content };
let config = LlmContentAnalysisConfig::default();
let result = analyzer.analyze_with_prompt_async(&input, &config).await;
assert!(
result.is_ok(),
"Analysis should succeed: {:?}",
result.err()
);
let analysis = result.unwrap();
assert!(
analysis.is_object(),
"Analysis result should be a JSON object"
);
assert!(
analysis.get("content_metadata").is_some(),
"Metadata should be present"
);
}
struct SyncTestAdapter {
analyzer: LlmContentAnalyzer,
}
impl SyncTestAdapter {
fn new(llm_service: Arc<LlmAnalysisService>) -> Self {
Self {
analyzer: LlmContentAnalyzer::new(llm_service),
}
}
fn analyze_with_prompt_sync(
&self,
input: &LlmContentAnalysisInput,
config: &LlmContentAnalysisConfig,
) -> Result<Value, String> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| format!("Failed to create runtime: {}", e))?;
rt.block_on(self.analyzer.analyze_with_prompt_async(input, config))
}
}
#[test]
fn test_default_prompt_content_analyzer() {
let mock_port = Arc::new(MockLlmPort);
let llm_service = Arc::new(LlmAnalysisService::new(mock_port));
let default_prompt = DefaultPromptContentAnalyzer::create_default_analysis_prompt()
.expect("Failed to create default prompt");
let config = LlmContentAnalysisConfig::default();
let sync_adapter = SyncTestAdapter::new(llm_service);
let text_content =
TextContent::new(None, Some("This is test content for analysis".to_string()))
.expect("Failed to create text content");
let content = ContentItem::new_with_title(
ContentType::Text(text_content),
"Test Content".to_string(),
)
.expect("Failed to create content item");
let input = LlmContentAnalysisInput {
prompt: default_prompt,
content,
};
let result = sync_adapter.analyze_with_prompt_sync(&input, &config);
assert!(
result.is_ok(),
"Analysis should succeed: {:?}",
result.err()
);
let analysis = result.unwrap();
assert!(
analysis.is_object(),
"Analysis result should be a JSON object"
);
}
#[tokio::test] async fn test_content_length_validation() {
let mock_port = Arc::new(MockLlmPort);
let llm_service = Arc::new(LlmAnalysisService::new(mock_port));
let analyzer = LlmContentAnalyzer::new(llm_service);
let long_content = "a".repeat(1000);
let text_content =
TextContent::new(None, Some(long_content)).expect("Failed to create text content");
let content = ContentItem::new_with_title(
ContentType::Text(text_content),
"Long Content".to_string(),
)
.expect("Failed to create content item");
let text_prompt = TextPrompt {
content: "Analyze this content.".to_string(),
role: PromptRole::User,
};
let prompt =
PromptItem::new_with_title(PromptType::Text(text_prompt), "Test Prompt".to_string())
.expect("Failed to create prompt");
let input = LlmContentAnalysisInput { prompt, content };
let config = LlmContentAnalysisConfig {
max_content_length: Some(100),
..Default::default()
};
let result = analyzer.analyze_with_prompt_async(&input, &config).await;
assert!(
result.is_err(),
"Analysis should fail for content exceeding length limit"
);
let error_msg = result.unwrap_err();
assert!(
error_msg.contains("exceeds maximum allowed length"),
"Error should mention length limit: {:?}",
error_msg
);
}
#[test]
fn test_sync_method_returns_error() {
let mock_port = Arc::new(MockLlmPort);
let llm_service = Arc::new(LlmAnalysisService::new(mock_port));
let analyzer = LlmContentAnalyzer::new(llm_service);
let text_content = TextContent::new(None, Some("Test content".to_string()))
.expect("Failed to create text content");
let content = ContentItem::new_with_title(
ContentType::Text(text_content),
"Test Content".to_string(),
)
.expect("Failed to create content item");
let text_prompt = TextPrompt {
content: "Test prompt".to_string(),
role: PromptRole::User,
};
let prompt =
PromptItem::new_with_title(PromptType::Text(text_prompt), "Test Prompt".to_string())
.expect("Failed to create prompt");
let input = LlmContentAnalysisInput { prompt, content };
let config = LlmContentAnalysisConfig::default();
let result = analyzer.analyze_with_prompt(&input, &config);
assert!(result.is_err(), "Sync method should return error");
let error_msg = result.unwrap_err();
assert!(
error_msg.contains("async"),
"Error should mention async: {:?}",
error_msg
);
}
#[tokio::test]
async fn test_empty_prompt_validation() {
let mock_port = Arc::new(MockLlmPort);
let llm_service = Arc::new(LlmAnalysisService::new(mock_port));
let analyzer = LlmContentAnalyzer::new(llm_service);
let text_content = TextContent::new(None, Some("Test content".to_string()))
.expect("Failed to create text content");
let content = ContentItem::new_with_title(
ContentType::Text(text_content),
"Test Content".to_string(),
)
.expect("Failed to create content item");
let text_prompt = TextPrompt {
content: "".to_string(), role: PromptRole::User,
};
let prompt =
PromptItem::new_with_title(PromptType::Text(text_prompt), "Empty Prompt".to_string())
.expect("Failed to create prompt");
let input = LlmContentAnalysisInput { prompt, content };
let config = LlmContentAnalysisConfig::default();
let result = analyzer.analyze_with_prompt_async(&input, &config).await;
assert!(result.is_err(), "Analysis should fail with empty prompt");
let error_msg = result.unwrap_err();
assert!(
error_msg.contains("cannot be empty"),
"Error should mention empty prompt: {:?}",
error_msg
);
}
}