music on atproto
plyr.fm
1//! Claude API client for image moderation using structured outputs.
2
3use base64::{engine::general_purpose::STANDARD, Engine};
4use serde::{Deserialize, Serialize};
5use tracing::info;
6
7const CLAUDE_API_URL: &str = "https://api.anthropic.com/v1/messages";
8const ANTHROPIC_VERSION: &str = "2023-06-01";
9const STRUCTURED_OUTPUTS_BETA: &str = "structured-outputs-2025-11-13";
10
11/// Result of image moderation analysis.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ModerationResult {
14 pub is_safe: bool,
15 pub violated_categories: Vec<String>,
16 pub severity: String,
17 pub explanation: String,
18}
19
20/// Claude API client for image moderation.
21pub struct ClaudeClient {
22 api_key: String,
23 model: String,
24 http: reqwest::Client,
25}
26
27impl ClaudeClient {
28 pub fn new(api_key: String, model: Option<String>) -> Self {
29 Self {
30 api_key,
31 model: model.unwrap_or_else(|| "claude-sonnet-4-5-20250929".to_string()),
32 http: reqwest::Client::new(),
33 }
34 }
35
36 /// Analyze an image for policy violations using structured outputs.
37 pub async fn analyze_image(
38 &self,
39 image_bytes: &[u8],
40 media_type: &str,
41 ) -> anyhow::Result<ModerationResult> {
42 let b64 = STANDARD.encode(image_bytes);
43
44 // Build request with structured output schema
45 let request = serde_json::json!({
46 "model": self.model,
47 "max_tokens": 1024,
48 "messages": [{
49 "role": "user",
50 "content": [
51 {
52 "type": "text",
53 "text": MODERATION_PROMPT
54 },
55 {
56 "type": "image",
57 "source": {
58 "type": "base64",
59 "media_type": media_type,
60 "data": b64
61 }
62 }
63 ]
64 }],
65 // Structured output schema - guarantees valid JSON matching this schema
66 "output_format": {
67 "type": "json_schema",
68 "schema": {
69 "type": "object",
70 "properties": {
71 "is_safe": {
72 "type": "boolean",
73 "description": "Whether the image passes moderation"
74 },
75 "violated_categories": {
76 "type": "array",
77 "items": { "type": "string" },
78 "description": "List of policy categories violated, empty if safe"
79 },
80 "severity": {
81 "type": "string",
82 "enum": ["safe", "low", "medium", "high"],
83 "description": "Severity level of the violation"
84 },
85 "explanation": {
86 "type": "string",
87 "description": "Brief explanation of the moderation decision"
88 }
89 },
90 "required": ["is_safe", "violated_categories", "severity", "explanation"],
91 "additionalProperties": false
92 }
93 }
94 });
95
96 info!(model = %self.model, "analyzing image with structured outputs");
97
98 let response = self
99 .http
100 .post(CLAUDE_API_URL)
101 .header("x-api-key", &self.api_key)
102 .header("anthropic-version", ANTHROPIC_VERSION)
103 .header("anthropic-beta", STRUCTURED_OUTPUTS_BETA)
104 .header("content-type", "application/json")
105 .json(&request)
106 .send()
107 .await?;
108
109 if !response.status().is_success() {
110 let status = response.status();
111 let body = response.text().await.unwrap_or_default();
112 anyhow::bail!("claude API error {status}: {body}");
113 }
114
115 let response: ClaudeResponse = response.json().await?;
116
117 // Check for refusal
118 if response.stop_reason == Some("refusal".to_string()) {
119 anyhow::bail!("claude refused to analyze the image");
120 }
121
122 // Check for max_tokens cutoff
123 if response.stop_reason == Some("max_tokens".to_string()) {
124 anyhow::bail!("response was cut off due to max_tokens limit");
125 }
126
127 // Extract text content - guaranteed to be valid JSON matching our schema
128 let text = response
129 .content
130 .into_iter()
131 .find_map(|block| {
132 if block.content_type == "text" {
133 block.text
134 } else {
135 None
136 }
137 })
138 .ok_or_else(|| anyhow::anyhow!("no text content in response"))?;
139
140 // Direct JSON parse - no string manipulation needed thanks to structured outputs
141 serde_json::from_str(&text)
142 .map_err(|e| anyhow::anyhow!("failed to parse structured output: {e}"))
143 }
144}
145
146#[derive(Debug, Deserialize)]
147struct ClaudeResponse {
148 content: Vec<ContentBlock>,
149 stop_reason: Option<String>,
150}
151
152#[derive(Debug, Deserialize)]
153struct ContentBlock {
154 #[serde(rename = "type")]
155 content_type: String,
156 text: Option<String>,
157}
158
159const MODERATION_PROMPT: &str = r#"You are a content moderator for a music streaming platform. Analyze the provided image (album/track cover art) for policy violations.
160
161Check for:
1621. Explicit sexual content (nudity, pornography)
1632. Extreme violence or gore
1643. Hate symbols or content
1654. Illegal content
1665. Graphic drug use imagery
167
168Note: Artistic nudity in album art (like classic rock covers) may be acceptable if not explicit/pornographic.
169
170Analyze the image and provide your moderation decision."#;
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_parse_safe_response() {
178 let response = r#"{"is_safe": true, "violated_categories": [], "severity": "safe", "explanation": "Normal album artwork"}"#;
179 let result: ModerationResult = serde_json::from_str(response).unwrap();
180 assert!(result.is_safe);
181 assert!(result.violated_categories.is_empty());
182 assert_eq!(result.severity, "safe");
183 }
184
185 #[test]
186 fn test_parse_unsafe_response() {
187 let response = r#"{"is_safe": false, "violated_categories": ["explicit_sexual"], "severity": "high", "explanation": "Contains explicit nudity"}"#;
188 let result: ModerationResult = serde_json::from_str(response).unwrap();
189 assert!(!result.is_safe);
190 assert_eq!(result.violated_categories, vec!["explicit_sexual"]);
191 assert_eq!(result.severity, "high");
192 }
193}