#![allow(clippy::unnecessary_literal_bound)]
use crate::agent::Suggestor;
use crate::context::{ContextKey, ProposedFact};
use crate::effect::AgentEffect;
use strum::IntoEnumIterator;
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub min_confidence: f64,
pub max_content_length: usize,
pub forbidden_terms: Vec<String>,
pub require_provenance: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
min_confidence: 0.5,
max_content_length: 10_000,
forbidden_terms: vec![],
require_provenance: true,
}
}
}
#[derive(Debug, Clone)]
pub enum ValidationResult {
Accepted(ProposedFact),
Rejected { proposal_id: String, reason: String },
}
pub struct ValidationAgent {
config: ValidationConfig,
}
impl ValidationAgent {
#[must_use]
pub fn new(config: ValidationConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ValidationConfig::default())
}
pub fn validate_proposal(&self, proposal: &ProposedFact) -> ValidationResult {
if proposal.confidence < self.config.min_confidence {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!(
"confidence {} below threshold {}",
proposal.confidence, self.config.min_confidence
),
};
}
if proposal.content.len() > self.config.max_content_length {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!(
"content length {} exceeds max {}",
proposal.content.len(),
self.config.max_content_length
),
};
}
if proposal.content.trim().is_empty() {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: "content is empty".into(),
};
}
if self.config.require_provenance && proposal.provenance.trim().is_empty() {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: "provenance is required but empty".into(),
};
}
let content_lower = proposal.content.to_lowercase();
for term in &self.config.forbidden_terms {
if content_lower.contains(&term.to_lowercase()) {
return ValidationResult::Rejected {
proposal_id: proposal.id.clone(),
reason: format!("content contains forbidden term '{term}'"),
};
}
}
ValidationResult::Accepted(proposal.clone())
}
}
#[async_trait::async_trait]
impl Suggestor for ValidationAgent {
fn name(&self) -> &str {
"ValidationAgent"
}
fn dependencies(&self) -> &[ContextKey] {
&[]
}
fn accepts(&self, ctx: &dyn crate::ContextView) -> bool {
ContextKey::iter().any(|key| !ctx.get_proposals(key).is_empty())
}
async fn execute(&self, ctx: &dyn crate::ContextView) -> AgentEffect {
let mut diagnostics = Vec::new();
for key in ContextKey::iter() {
for proposal in ctx.get_proposals(key) {
if let ValidationResult::Rejected {
proposal_id,
reason,
} = self.validate_proposal(proposal)
{
diagnostics.push(
ProposedFact::new(
ContextKey::Diagnostic,
format!("validation:rejected:{proposal_id}"),
format!("Proposal '{proposal_id}' rejected: {reason}"),
self.name(),
)
.with_confidence(1.0),
);
}
}
}
AgentEffect::with_proposals(diagnostics)
}
}
#[must_use]
pub fn encode_proposal(proposal: &ProposedFact) -> ProposedFact {
proposal.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_accepts_good_proposal() {
let agent = ValidationAgent::with_defaults();
let proposal = ProposedFact::new(
ContextKey::Hypotheses,
"hyp-1",
"Market is growing",
"gpt-4:abc123",
)
.with_confidence(0.8);
match agent.validate_proposal(&proposal) {
ValidationResult::Accepted(accepted) => {
assert_eq!(accepted.id, "hyp-1");
}
ValidationResult::Rejected { reason, .. } => {
panic!("Expected acceptance, got rejection: {reason}");
}
}
}
#[test]
fn validation_rejects_low_confidence() {
let agent = ValidationAgent::new(ValidationConfig {
min_confidence: 0.7,
..Default::default()
});
let proposal =
ProposedFact::new(ContextKey::Hypotheses, "hyp-1", "Uncertain claim", "gpt-4")
.with_confidence(0.3);
match agent.validate_proposal(&proposal) {
ValidationResult::Rejected { reason, .. } => {
assert!(reason.contains("confidence"));
}
ValidationResult::Accepted(_) => {
panic!("Expected rejection for low confidence");
}
}
}
}