use serde::{Deserialize, Serialize};
use crate::error::{AiError, Result};
use crate::llm::{ChatMessage, ChatRequest, ChatRole, LlmClient};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoricalCommitment {
pub id: String,
pub description: String,
pub target_amount: u64,
pub delivered_amount: Option<u64>,
pub completed: bool,
pub days_to_complete: Option<u32>,
pub quality_score: Option<f64>,
pub evidence_quality: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuerHistory {
pub issuer_id: String,
pub total_commitments: usize,
pub completed_commitments: usize,
pub avg_completion_days: Option<f64>,
pub avg_quality_score: Option<f64>,
pub commitments: Vec<HistoricalCommitment>,
pub days_active: u32,
pub reputation_tier: String,
pub reputation_score: f64,
}
impl IssuerHistory {
#[must_use]
pub fn completion_rate(&self) -> f64 {
if self.total_commitments == 0 {
0.0
} else {
(self.completed_commitments as f64 / self.total_commitments as f64) * 100.0
}
}
#[must_use]
pub fn recent_trend(&self) -> Trend {
if self.commitments.len() < 2 {
return Trend::Neutral;
}
let recent: Vec<_> = self.commitments.iter().rev().take(5).collect();
let recent_completed = recent.iter().filter(|c| c.completed).count();
let recent_rate = recent_completed as f64 / recent.len() as f64;
let overall_rate = self.completion_rate() / 100.0;
if recent_rate > overall_rate + 0.2 {
Trend::Improving
} else if recent_rate < overall_rate - 0.2 {
Trend::Declining
} else {
Trend::Neutral
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Trend {
Improving,
Neutral,
Declining,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitmentPrediction {
pub completion_probability: f64,
pub expected_quality: f64,
pub expected_days: Option<u32>,
pub risk_factors: Vec<String>,
pub positive_indicators: Vec<String>,
pub confidence: f64,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuerRiskAssessment {
pub risk_score: f64,
pub risk_level: RiskLevel,
pub recommended_limit: Option<u64>,
pub risk_factors: Vec<RiskFactor>,
pub mitigations: Vec<String>,
pub confidence: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RiskLevel {
VeryLow,
Low,
Medium,
High,
VeryHigh,
}
impl RiskLevel {
#[must_use]
pub fn from_score(score: f64) -> Self {
match score {
s if s < 20.0 => RiskLevel::VeryLow,
s if s < 40.0 => RiskLevel::Low,
s if s < 60.0 => RiskLevel::Medium,
s if s < 80.0 => RiskLevel::High,
_ => RiskLevel::VeryHigh,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskFactor {
pub name: String,
pub description: String,
pub severity: f64,
pub impact: Impact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Impact {
Low,
Medium,
High,
}
pub struct ReputationPredictor {
llm: LlmClient,
}
impl ReputationPredictor {
#[must_use]
pub fn new(llm: LlmClient) -> Self {
Self { llm }
}
pub async fn predict_commitment_success(
&self,
commitment: &str,
target_amount: u64,
issuer_history: &IssuerHistory,
) -> Result<CommitmentPrediction> {
let prompt = self.build_prediction_prompt(commitment, target_amount, issuer_history);
let request = ChatRequest {
messages: vec![
ChatMessage {
role: ChatRole::System,
content: "You are an expert at predicting project success and commitment completion based on historical data. Analyze the issuer's history and provide a detailed prediction.".to_string(),
},
ChatMessage {
role: ChatRole::User,
content: prompt,
},
],
temperature: Some(0.3),
max_tokens: Some(1000),
stop: None,
images: None,
};
let response = self.llm.chat(request).await?;
self.parse_prediction_response(&response.message.content, issuer_history)
}
fn build_prediction_prompt(
&self,
commitment: &str,
target_amount: u64,
history: &IssuerHistory,
) -> String {
format!(
r#"Analyze this commitment and predict its success probability:
COMMITMENT: {}
TARGET AMOUNT: {} sats
ISSUER HISTORY:
- Total Commitments: {}
- Completed: {} ({:.1}% completion rate)
- Average Completion Time: {} days
- Average Quality Score: {:.1}/100
- Recent Trend: {:?}
- Reputation Tier: {}
- Reputation Score: {:.1}
- Days Active: {}
RECENT COMMITMENTS:
{}
Provide a structured prediction including:
1. Completion probability (0-100%)
2. Expected quality if completed (0-100)
3. Expected days to completion
4. Risk factors (bullet points)
5. Positive indicators (bullet points)
6. Detailed reasoning
Format your response as JSON with these fields:
{{
"completion_probability": <number>,
"expected_quality": <number>,
"expected_days": <number or null>,
"risk_factors": [<strings>],
"positive_indicators": [<strings>],
"reasoning": "<detailed explanation>"
}}
"#,
commitment,
target_amount,
history.total_commitments,
history.completed_commitments,
history.completion_rate(),
history
.avg_completion_days
.map_or_else(|| "N/A".to_string(), |d| format!("{d:.1}")),
history.avg_quality_score.unwrap_or(0.0),
history.recent_trend(),
history.reputation_tier,
history.reputation_score,
history.days_active,
self.format_recent_commitments(&history.commitments),
)
}
fn format_recent_commitments(&self, commitments: &[HistoricalCommitment]) -> String {
commitments
.iter()
.rev()
.take(5)
.map(|c| {
format!(
"- {} (Target: {} sats, Completed: {}, Quality: {:.0}/100)",
c.description,
c.target_amount,
if c.completed { "Yes" } else { "No" },
c.quality_score.unwrap_or(0.0)
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn parse_prediction_response(
&self,
content: &str,
_history: &IssuerHistory,
) -> Result<CommitmentPrediction> {
let json_str = content
.find('{')
.and_then(|start| {
content[start..]
.rfind('}')
.map(|end| &content[start..=(start + end)])
})
.ok_or_else(|| AiError::ParseError("No JSON found in response".to_string()))?;
#[derive(Deserialize)]
struct ParsedPrediction {
completion_probability: f64,
expected_quality: f64,
expected_days: Option<u32>,
risk_factors: Vec<String>,
positive_indicators: Vec<String>,
reasoning: String,
}
let parsed: ParsedPrediction = serde_json::from_str(json_str)
.map_err(|e| AiError::ParseError(format!("Failed to parse prediction: {e}")))?;
Ok(CommitmentPrediction {
completion_probability: parsed.completion_probability.clamp(0.0, 100.0),
expected_quality: parsed.expected_quality.clamp(0.0, 100.0),
expected_days: parsed.expected_days,
risk_factors: parsed.risk_factors,
positive_indicators: parsed.positive_indicators,
confidence: 75.0, reasoning: parsed.reasoning,
})
}
pub async fn assess_new_issuer_risk(
&self,
issuer_info: &NewIssuerInfo,
) -> Result<IssuerRiskAssessment> {
let prompt = self.build_risk_assessment_prompt(issuer_info);
let request = ChatRequest {
messages: vec![
ChatMessage {
role: ChatRole::System,
content: "You are an expert at assessing risk for new token issuers. Analyze the provided information and identify potential risks.".to_string(),
},
ChatMessage {
role: ChatRole::User,
content: prompt,
},
],
temperature: Some(0.3),
max_tokens: Some(1000),
stop: None,
images: None,
};
let response = self.llm.chat(request).await?;
self.parse_risk_assessment_response(&response.message.content, issuer_info)
}
fn build_risk_assessment_prompt(&self, info: &NewIssuerInfo) -> String {
format!(
r#"Assess the risk for this new token issuer:
ISSUER INFORMATION:
- Has GitHub: {}
- GitHub Repos: {}
- GitHub Followers: {}
- Has Twitter: {}
- Twitter Followers: {}
- Has LinkedIn: {}
- Years of Experience: {}
- Previous Projects: {}
- Token Description: {}
- Requested Initial Limit: {} sats
Provide a comprehensive risk assessment including:
1. Overall risk score (0-100, higher = more risky)
2. Specific risk factors with severity
3. Recommended initial commitment limit
4. Mitigation strategies
Format as JSON:
{{
"risk_score": <number>,
"risk_factors": [
{{"name": "<string>", "description": "<string>", "severity": <number>, "impact": "Low|Medium|High"}}
],
"recommended_limit": <number or null>,
"mitigations": [<strings>],
"reasoning": "<detailed explanation>"
}}
"#,
info.has_github,
info.github_repos.unwrap_or(0),
info.github_followers.unwrap_or(0),
info.has_twitter,
info.twitter_followers.unwrap_or(0),
info.has_linkedin,
info.years_experience.unwrap_or(0),
info.previous_projects.len(),
info.token_description,
info.requested_limit,
)
}
fn parse_risk_assessment_response(
&self,
content: &str,
_info: &NewIssuerInfo,
) -> Result<IssuerRiskAssessment> {
let json_str = content
.find('{')
.and_then(|start| {
content[start..]
.rfind('}')
.map(|end| &content[start..=(start + end)])
})
.ok_or_else(|| AiError::ParseError("No JSON found in response".to_string()))?;
#[derive(Deserialize)]
struct ParsedRisk {
risk_score: f64,
risk_factors: Vec<ParsedRiskFactor>,
recommended_limit: Option<u64>,
mitigations: Vec<String>,
}
#[derive(Deserialize)]
struct ParsedRiskFactor {
name: String,
description: String,
severity: f64,
impact: String,
}
let parsed: ParsedRisk = serde_json::from_str(json_str)
.map_err(|e| AiError::ParseError(format!("Failed to parse risk assessment: {e}")))?;
let risk_factors = parsed
.risk_factors
.into_iter()
.map(|f| RiskFactor {
name: f.name,
description: f.description,
severity: f.severity.clamp(0.0, 100.0),
impact: match f.impact.to_lowercase().as_str() {
"high" => Impact::High,
"medium" => Impact::Medium,
_ => Impact::Low,
},
})
.collect();
let risk_score = parsed.risk_score.clamp(0.0, 100.0);
Ok(IssuerRiskAssessment {
risk_score,
risk_level: RiskLevel::from_score(risk_score),
recommended_limit: parsed.recommended_limit,
risk_factors,
mitigations: parsed.mitigations,
confidence: 70.0,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewIssuerInfo {
pub token_description: String,
pub requested_limit: u64,
pub has_github: bool,
pub github_repos: Option<u32>,
pub github_followers: Option<u32>,
pub has_twitter: bool,
pub twitter_followers: Option<u32>,
pub has_linkedin: bool,
pub years_experience: Option<u32>,
pub previous_projects: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issuer_history_completion_rate() {
let history = IssuerHistory {
issuer_id: "test".to_string(),
total_commitments: 10,
completed_commitments: 8,
avg_completion_days: Some(15.0),
avg_quality_score: Some(85.0),
commitments: vec![],
days_active: 100,
reputation_tier: "Silver".to_string(),
reputation_score: 75.0,
};
assert_eq!(history.completion_rate(), 80.0);
}
#[test]
fn test_risk_level_from_score() {
assert_eq!(RiskLevel::from_score(10.0), RiskLevel::VeryLow);
assert_eq!(RiskLevel::from_score(30.0), RiskLevel::Low);
assert_eq!(RiskLevel::from_score(50.0), RiskLevel::Medium);
assert_eq!(RiskLevel::from_score(70.0), RiskLevel::High);
assert_eq!(RiskLevel::from_score(90.0), RiskLevel::VeryHigh);
}
#[test]
fn test_trend_detection() {
let history = IssuerHistory {
issuer_id: "test".to_string(),
total_commitments: 3,
completed_commitments: 3,
avg_completion_days: Some(15.0),
avg_quality_score: Some(85.0),
commitments: vec![
HistoricalCommitment {
id: "1".to_string(),
description: "Test 1".to_string(),
target_amount: 1000,
delivered_amount: Some(1000),
completed: true,
days_to_complete: Some(10),
quality_score: Some(80.0),
evidence_quality: Some(90.0),
},
HistoricalCommitment {
id: "2".to_string(),
description: "Test 2".to_string(),
target_amount: 1000,
delivered_amount: Some(1000),
completed: true,
days_to_complete: Some(12),
quality_score: Some(85.0),
evidence_quality: Some(92.0),
},
HistoricalCommitment {
id: "3".to_string(),
description: "Test 3".to_string(),
target_amount: 1000,
delivered_amount: Some(1000),
completed: true,
days_to_complete: Some(8),
quality_score: Some(90.0),
evidence_quality: Some(95.0),
},
],
days_active: 100,
reputation_tier: "Silver".to_string(),
reputation_score: 75.0,
};
let trend = history.recent_trend();
assert_eq!(trend, Trend::Neutral);
}
}