feat: add moderation agent script with audit trail support (#586)

- add AI-powered moderation agent (scripts/moderation_agent.py) that:
- fetches pending copyright flags from moderation service
- uses Claude to categorize as violation/false positive/needs review
- shows reasoning for each decision in scannable table format
- bulk resolves false positives with human approval

- fix JSON API endpoint to store resolution reason and notes
(previously only the htmx endpoint saved this data)

- update admin UI to display resolution notes prominently
for resolved items instead of hiding in tooltip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 5965626e 5227c79c

Changed files
+669 -8
moderation
src
static
scripts
+27 -6
moderation/src/admin.rs
··· 149 .as_ref() 150 .ok_or(AppError::LabelerNotConfigured)?; 151 152 - tracing::info!(uri = %request.uri, val = %request.val, "resolving flag (creating negation)"); 153 154 // Create a negation label 155 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); 156 let label = signer.sign_label(label)?; 157 158 let seq = db.store_label(&label).await?; 159 160 // Broadcast to subscribers 161 if let Some(tx) = &state.label_tx { ··· 432 .unwrap_or_default(); 433 434 let action_button = if track.resolved { 435 - // Show the resolution reason if available 436 let reason_text = ctx 437 .and_then(|c| c.resolution_reason.as_ref()) 438 .map(|r| r.label()) 439 .unwrap_or("resolved"); 440 - let notes_text = ctx 441 .and_then(|c| c.resolution_notes.as_ref()) 442 - .map(|n| format!(r#" title="{}""#, html_escape(n))) 443 .unwrap_or_default(); 444 format!( 445 - r#"<span class="resolution-reason"{}>{}</span>"#, 446 - notes_text, reason_text 447 ) 448 } else { 449 // Multi-step flow: button -> reason select -> confirm
··· 149 .as_ref() 150 .ok_or(AppError::LabelerNotConfigured)?; 151 152 + // Parse the reason 153 + let reason = request 154 + .reason 155 + .as_deref() 156 + .and_then(crate::db::ResolutionReason::from_str); 157 + 158 + tracing::info!( 159 + uri = %request.uri, 160 + val = %request.val, 161 + reason = ?reason, 162 + notes = ?request.notes, 163 + "resolving flag (creating negation)" 164 + ); 165 166 // Create a negation label 167 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); 168 let label = signer.sign_label(label)?; 169 170 let seq = db.store_label(&label).await?; 171 + 172 + // Store resolution reason in context 173 + if let Some(r) = reason { 174 + db.store_resolution(&request.uri, r, request.notes.as_deref()) 175 + .await?; 176 + } 177 178 // Broadcast to subscribers 179 if let Some(tx) = &state.label_tx { ··· 450 .unwrap_or_default(); 451 452 let action_button = if track.resolved { 453 + // Show the resolution reason and notes if available 454 let reason_text = ctx 455 .and_then(|c| c.resolution_reason.as_ref()) 456 .map(|r| r.label()) 457 .unwrap_or("resolved"); 458 + let notes_html = ctx 459 .and_then(|c| c.resolution_notes.as_ref()) 460 + .map(|n| format!(r#"<div class="resolution-notes">{}</div>"#, html_escape(n))) 461 .unwrap_or_default(); 462 format!( 463 + r#"<div class="resolution-info"> 464 + <span class="resolution-reason">{}</span> 465 + {} 466 + </div>"#, 467 + reason_text, notes_html 468 ) 469 } else { 470 // Multi-step flow: button -> reason select -> confirm
+20 -2
moderation/static/admin.css
··· 323 border-top: 1px solid var(--border-subtle); 324 } 325 326 - /* resolution reason display */ 327 .resolution-reason { 328 color: var(--success); 329 font-size: 0.85rem; 330 font-weight: 500; 331 - cursor: help; 332 } 333 334 /* multi-step resolve flow */
··· 323 border-top: 1px solid var(--border-subtle); 324 } 325 326 + /* resolution info display */ 327 + .resolution-info { 328 + display: flex; 329 + flex-direction: column; 330 + gap: 8px; 331 + align-items: flex-end; 332 + } 333 + 334 .resolution-reason { 335 color: var(--success); 336 font-size: 0.85rem; 337 font-weight: 500; 338 + } 339 + 340 + .resolution-notes { 341 + background: var(--bg-primary); 342 + border: 1px solid var(--border-subtle); 343 + border-radius: 6px; 344 + padding: 12px; 345 + font-size: 0.8rem; 346 + color: var(--text-secondary); 347 + line-height: 1.5; 348 + max-width: 500px; 349 + text-align: left; 350 } 351 352 /* multi-step resolve flow */
+622
scripts/moderation_agent.py
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [ 5 + # "pydantic-ai>=0.1.0", 6 + # "anthropic", 7 + # "httpx", 8 + # "pydantic>=2.0", 9 + # "pydantic-settings", 10 + # "rich", 11 + # ] 12 + # /// 13 + """AI-powered moderation review agent for plyr.fm copyright flags. 14 + 15 + this agent: 16 + 1. fetches all pending copyright flags from the moderation service 17 + 2. analyzes each flag using AI to categorize as likely violation or false positive 18 + 3. presents a summary for human review 19 + 4. bulk resolves flags with human approval 20 + 21 + usage: 22 + uv run scripts/moderation_agent.py --env prod 23 + uv run scripts/moderation_agent.py --env prod --dry-run 24 + uv run scripts/moderation_agent.py --env staging --auto-resolve 25 + 26 + environment variables: 27 + MODERATION_SERVICE_URL - URL of moderation service (default: https://plyr-moderation.fly.dev) 28 + MODERATION_AUTH_TOKEN - auth token for moderation service 29 + ANTHROPIC_API_KEY - API key for Claude 30 + """ 31 + 32 + from __future__ import annotations 33 + 34 + import argparse 35 + import asyncio 36 + from dataclasses import dataclass, field 37 + from enum import Enum 38 + from pathlib import Path 39 + from typing import Literal 40 + 41 + import httpx 42 + from pydantic import BaseModel, Field 43 + from pydantic_ai import Agent 44 + from pydantic_ai.models.anthropic import AnthropicModel 45 + from pydantic_settings import BaseSettings, SettingsConfigDict 46 + from rich.console import Console 47 + from rich.panel import Panel 48 + from rich.prompt import Confirm 49 + from rich.table import Table 50 + 51 + console = Console() 52 + 53 + 54 + # --- settings --- 55 + 56 + 57 + class AgentSettings(BaseSettings): 58 + """settings for moderation agent.""" 59 + 60 + model_config = SettingsConfigDict( 61 + env_file=Path(__file__).parent.parent / ".env", 62 + case_sensitive=False, 63 + extra="ignore", 64 + ) 65 + 66 + moderation_service_url: str = Field( 67 + default="https://moderation.plyr.fm", 68 + validation_alias="MODERATION_SERVICE_URL", 69 + ) 70 + moderation_auth_token: str = Field( 71 + default="", validation_alias="MODERATION_AUTH_TOKEN" 72 + ) 73 + anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") 74 + 75 + 76 + # --- models --- 77 + 78 + 79 + class CopyrightMatch(BaseModel): 80 + """a potential copyright match from AuDD.""" 81 + 82 + title: str 83 + artist: str 84 + score: float 85 + 86 + 87 + class LabelContext(BaseModel): 88 + """context stored with a copyright flag.""" 89 + 90 + track_id: int | None = None 91 + track_title: str | None = None 92 + artist_handle: str | None = None 93 + artist_did: str | None = None 94 + highest_score: float | None = None 95 + matches: list[CopyrightMatch] | None = None 96 + resolution_reason: str | None = None 97 + resolution_notes: str | None = None 98 + 99 + 100 + class FlaggedTrack(BaseModel): 101 + """a flagged track pending review.""" 102 + 103 + seq: int 104 + uri: str 105 + val: str 106 + created_at: str 107 + resolved: bool 108 + context: LabelContext | None = None 109 + 110 + 111 + class Category(str, Enum): 112 + """classification category for a flagged track.""" 113 + 114 + LIKELY_VIOLATION = "likely_violation" 115 + LIKELY_FALSE_POSITIVE = "likely_false_positive" 116 + NEEDS_REVIEW = "needs_review" 117 + 118 + 119 + class ResolutionReason(str, Enum): 120 + """reason for resolving a false positive.""" 121 + 122 + ORIGINAL_ARTIST = "original_artist" 123 + LICENSED = "licensed" 124 + FINGERPRINT_NOISE = "fingerprint_noise" 125 + COVER_VERSION = "cover_version" 126 + OTHER = "other" 127 + 128 + 129 + class FlagAnalysis(BaseModel): 130 + """AI analysis of a single flagged track.""" 131 + 132 + category: Category 133 + confidence: float = Field(ge=0.0, le=1.0) 134 + reasoning: str 135 + suggested_reason: ResolutionReason | None = None 136 + 137 + 138 + class BatchAnalysis(BaseModel): 139 + """AI analysis of a batch of flagged tracks.""" 140 + 141 + likely_violations: list[str] = Field( 142 + default_factory=list, description="URIs of tracks likely violating copyright" 143 + ) 144 + likely_false_positives: list[str] = Field( 145 + default_factory=list, description="URIs of tracks likely false positives" 146 + ) 147 + needs_review: list[str] = Field( 148 + default_factory=list, description="URIs needing human review" 149 + ) 150 + summary: str = Field(description="brief summary of the analysis") 151 + per_track_analysis: dict[str, FlagAnalysis] = Field( 152 + default_factory=dict, description="detailed analysis per URI" 153 + ) 154 + 155 + 156 + # --- moderation service client --- 157 + 158 + 159 + @dataclass 160 + class ModerationClient: 161 + """client for the moderation service API.""" 162 + 163 + base_url: str 164 + auth_token: str 165 + _client: httpx.AsyncClient = field(init=False, repr=False) 166 + 167 + def __post_init__(self) -> None: 168 + self._client = httpx.AsyncClient( 169 + base_url=self.base_url, 170 + headers={"X-Moderation-Key": self.auth_token}, 171 + timeout=30.0, 172 + ) 173 + 174 + async def close(self) -> None: 175 + await self._client.aclose() 176 + 177 + async def list_flags( 178 + self, filter: Literal["pending", "resolved", "all"] = "pending" 179 + ) -> list[FlaggedTrack]: 180 + """list flagged tracks from the moderation service.""" 181 + response = await self._client.get("/admin/flags", params={"filter": filter}) 182 + response.raise_for_status() 183 + data = response.json() 184 + return [FlaggedTrack.model_validate(t) for t in data["tracks"]] 185 + 186 + async def resolve_flag( 187 + self, 188 + uri: str, 189 + reason: ResolutionReason, 190 + notes: str | None = None, 191 + ) -> dict: 192 + """resolve (negate) a copyright flag.""" 193 + payload = { 194 + "uri": uri, 195 + "val": "copyright-violation", 196 + "reason": reason.value, 197 + } 198 + if notes: 199 + payload["notes"] = notes 200 + response = await self._client.post("/admin/resolve", json=payload) 201 + response.raise_for_status() 202 + return response.json() 203 + 204 + 205 + # --- agent setup --- 206 + 207 + SYSTEM_PROMPT = """\ 208 + you are a copyright moderation analyst for plyr.fm, a music streaming platform. 209 + 210 + your task is to review flagged tracks and categorize them as: 211 + - LIKELY_VIOLATION: high confidence this is actual copyright infringement 212 + - LIKELY_FALSE_POSITIVE: high confidence this is NOT infringement (original artist, licensed, etc.) 213 + - NEEDS_REVIEW: uncertain, requires human judgment 214 + 215 + when analyzing flags, consider: 216 + 217 + 1. ORIGINAL ARTIST indicators (false positive): 218 + - artist handle matches or is similar to matched artist name 219 + - track title matches the uploaded track title 220 + - artist is likely uploading their own distributed music 221 + 222 + 2. FINGERPRINT NOISE indicators (false positive): 223 + - very low match scores (< 0.5) 224 + - generic/common samples or sounds 225 + - matched songs from different genres than uploaded track 226 + - one match among many unrelated matches 227 + 228 + 3. LICENSED/COVER indicators (false positive): 229 + - track explicitly labeled as cover, remix, or tribute 230 + - common phrases in titles suggesting original content 231 + 232 + 4. LIKELY VIOLATION indicators: 233 + - high match scores (> 0.8) with well-known commercial tracks 234 + - exact title matches with popular songs 235 + - matched artist is clearly different from uploader 236 + - multiple matches to same copyrighted work 237 + 238 + be conservative: when in doubt, categorize as NEEDS_REVIEW rather than auto-resolving. 239 + provide clear reasoning for each categorization. 240 + 241 + for false positives, suggest the most appropriate resolution reason: 242 + - original_artist: uploader is the matched artist 243 + - licensed: uploader has rights to use the content 244 + - fingerprint_noise: audio fingerprinting error 245 + - cover_version: legal cover or remix 246 + - other: doesn't fit other categories 247 + """ 248 + 249 + 250 + def create_agent(api_key: str) -> Agent[None, BatchAnalysis]: 251 + """create the moderation analysis agent.""" 252 + from pydantic_ai.providers.anthropic import AnthropicProvider 253 + 254 + provider = AnthropicProvider(api_key=api_key) 255 + return Agent( 256 + model=AnthropicModel("claude-sonnet-4-20250514", provider=provider), 257 + output_type=BatchAnalysis, 258 + system_prompt=SYSTEM_PROMPT, 259 + ) 260 + 261 + 262 + # --- main logic --- 263 + 264 + 265 + def format_track_for_analysis(track: FlaggedTrack) -> str: 266 + """format a track for inclusion in agent prompt.""" 267 + ctx = track.context 268 + lines = [f"URI: {track.uri}"] 269 + 270 + if ctx: 271 + if ctx.track_title: 272 + lines.append(f"Uploaded Track: {ctx.track_title}") 273 + if ctx.artist_handle: 274 + lines.append(f"Uploader: @{ctx.artist_handle}") 275 + if ctx.matches: 276 + lines.append("Copyright Matches:") 277 + for m in ctx.matches[:5]: # limit to top 5 278 + lines.append(f" - '{m.title}' by {m.artist} (score: {m.score:.2f})") 279 + else: 280 + lines.append("(no context available)") 281 + 282 + return "\n".join(lines) 283 + 284 + 285 + def truncate(s: str, max_len: int) -> str: 286 + """truncate string with ellipsis if needed.""" 287 + if len(s) <= max_len: 288 + return s 289 + return s[: max_len - 1] + "…" 290 + 291 + 292 + def display_analysis_summary( 293 + analysis: BatchAnalysis, 294 + tracks: dict[str, FlaggedTrack], 295 + ) -> None: 296 + """display a rich summary of the analysis.""" 297 + console.print() 298 + console.print( 299 + Panel(analysis.summary, title="analysis summary", border_style="blue") 300 + ) 301 + 302 + # likely violations 303 + if analysis.likely_violations: 304 + table = Table( 305 + title="likely violations", 306 + border_style="red", 307 + show_lines=True, 308 + padding=(0, 1), 309 + ) 310 + table.add_column("#", style="dim", width=3) 311 + table.add_column("track", style="red", max_width=25) 312 + table.add_column("matches", max_width=30) 313 + table.add_column("conf", width=5) 314 + table.add_column("reasoning", max_width=50) 315 + 316 + for i, uri in enumerate(analysis.likely_violations, 1): 317 + track = tracks.get(uri) 318 + info = analysis.per_track_analysis.get(uri) 319 + ctx = track.context if track else None 320 + 321 + title = truncate(ctx.track_title, 24) if ctx and ctx.track_title else "-" 322 + matches = ( 323 + truncate(", ".join(f"{m.artist}" for m in ctx.matches[:2]), 29) 324 + if ctx and ctx.matches 325 + else "-" 326 + ) 327 + conf = f"{info.confidence:.0%}" if info else "-" 328 + reasoning = truncate(info.reasoning, 49) if info else "-" 329 + 330 + table.add_row(str(i), title, matches, conf, reasoning) 331 + 332 + console.print(table) 333 + 334 + # likely false positives 335 + if analysis.likely_false_positives: 336 + table = Table( 337 + title="likely false positives", 338 + border_style="green", 339 + padding=(0, 1), 340 + ) 341 + table.add_column("#", style="dim", width=3) 342 + table.add_column("track", style="green", max_width=30) 343 + table.add_column("artist", max_width=18) 344 + table.add_column("reason", width=18) 345 + table.add_column("conf", width=5) 346 + 347 + for i, uri in enumerate(analysis.likely_false_positives, 1): 348 + track = tracks.get(uri) 349 + info = analysis.per_track_analysis.get(uri) 350 + ctx = track.context if track else None 351 + 352 + title = truncate(ctx.track_title, 29) if ctx and ctx.track_title else "-" 353 + artist = ( 354 + truncate(f"@{ctx.artist_handle}", 17) 355 + if ctx and ctx.artist_handle 356 + else "-" 357 + ) 358 + reason = ( 359 + info.suggested_reason.value if info and info.suggested_reason else "-" 360 + ) 361 + conf = f"{info.confidence:.0%}" if info else "-" 362 + 363 + table.add_row(str(i), title, artist, reason, conf) 364 + 365 + console.print(table) 366 + 367 + # show full reasoning below 368 + console.print() 369 + console.print("[bold]reasoning:[/bold]") 370 + for i, uri in enumerate(analysis.likely_false_positives, 1): 371 + info = analysis.per_track_analysis.get(uri) 372 + if info: 373 + console.print(f" [dim]{i}.[/dim] {info.reasoning}") 374 + 375 + # needs review 376 + if analysis.needs_review: 377 + table = Table( 378 + title="needs manual review", 379 + border_style="yellow", 380 + show_lines=True, 381 + padding=(0, 1), 382 + ) 383 + table.add_column("#", style="dim", width=3) 384 + table.add_column("track", style="yellow", max_width=25) 385 + table.add_column("artist", max_width=15) 386 + table.add_column("matches", max_width=25) 387 + table.add_column("reasoning", max_width=50) 388 + 389 + for i, uri in enumerate(analysis.needs_review, 1): 390 + track = tracks.get(uri) 391 + info = analysis.per_track_analysis.get(uri) 392 + ctx = track.context if track else None 393 + 394 + title = truncate(ctx.track_title, 24) if ctx and ctx.track_title else "-" 395 + artist = ( 396 + truncate(f"@{ctx.artist_handle}", 14) 397 + if ctx and ctx.artist_handle 398 + else "-" 399 + ) 400 + matches = ( 401 + truncate(", ".join(f"{m.artist}" for m in ctx.matches[:2]), 24) 402 + if ctx and ctx.matches 403 + else "-" 404 + ) 405 + reasoning = truncate(info.reasoning, 49) if info else "-" 406 + 407 + table.add_row(str(i), title, artist, matches, reasoning) 408 + 409 + console.print(table) 410 + 411 + # summary stats 412 + console.print() 413 + console.print("[bold]totals:[/bold]") 414 + console.print(f" likely violations: [red]{len(analysis.likely_violations)}[/red]") 415 + console.print( 416 + f" likely false positives: [green]{len(analysis.likely_false_positives)}[/green]" 417 + ) 418 + console.print(f" needs review: [yellow]{len(analysis.needs_review)}[/yellow]") 419 + 420 + 421 + async def run_agent( 422 + env: str, 423 + dry_run: bool = False, 424 + auto_resolve: bool = False, 425 + limit: int | None = None, 426 + ) -> None: 427 + """run the moderation agent.""" 428 + settings = AgentSettings() 429 + 430 + if not settings.moderation_auth_token: 431 + console.print("[red]error: MODERATION_AUTH_TOKEN not set[/red]") 432 + return 433 + 434 + if not settings.anthropic_api_key: 435 + console.print("[red]error: ANTHROPIC_API_KEY not set[/red]") 436 + return 437 + 438 + console.print(f"[bold]moderation agent[/bold] - {env}") 439 + console.print(f"service: {settings.moderation_service_url}") 440 + console.print() 441 + 442 + # fetch pending flags 443 + client = ModerationClient( 444 + base_url=settings.moderation_service_url, 445 + auth_token=settings.moderation_auth_token, 446 + ) 447 + 448 + try: 449 + console.print("[dim]fetching pending flags...[/dim]") 450 + flags = await client.list_flags(filter="pending") 451 + 452 + if not flags: 453 + console.print("[green]no pending flags[/green]") 454 + return 455 + 456 + if limit: 457 + flags = flags[:limit] 458 + console.print(f"[bold]found {len(flags)} pending flags (limited)[/bold]") 459 + else: 460 + console.print(f"[bold]found {len(flags)} pending flags[/bold]") 461 + 462 + # build analysis prompt 463 + tracks_by_uri = {f.uri: f for f in flags} 464 + track_descriptions = [format_track_for_analysis(f) for f in flags] 465 + 466 + # process in batches to avoid context limits 467 + batch_size = 20 468 + all_analyses: list[BatchAnalysis] = [] 469 + agent = create_agent(settings.anthropic_api_key) 470 + 471 + for batch_start in range(0, len(flags), batch_size): 472 + batch_end = min(batch_start + batch_size, len(flags)) 473 + batch_flags = flags[batch_start:batch_end] 474 + batch_descriptions = track_descriptions[batch_start:batch_end] 475 + 476 + console.print( 477 + f"[dim]analyzing batch {batch_start // batch_size + 1} " 478 + f"({batch_start + 1}-{batch_end} of {len(flags)})...[/dim]" 479 + ) 480 + 481 + prompt = f"""\ 482 + analyze these {len(batch_flags)} flagged tracks and categorize EACH one. 483 + 484 + IMPORTANT: You MUST include EVERY track URI in exactly one of these lists: 485 + - likely_violations 486 + - likely_false_positives 487 + - needs_review 488 + 489 + Also provide per_track_analysis with details for each URI. 490 + 491 + --- 492 + {chr(10).join(f"### Track {i + 1}{chr(10)}{desc}{chr(10)}" for i, desc in enumerate(batch_descriptions))} 493 + --- 494 + 495 + For each track: 496 + 1. Add its URI to the appropriate category list 497 + 2. Add an entry to per_track_analysis with the URI as key 498 + 3. Include confidence (0.0-1.0), reasoning, and suggested_reason for false positives 499 + """ 500 + 501 + result = await agent.run(prompt) 502 + all_analyses.append(result.output) 503 + 504 + # merge all batch results 505 + analysis = BatchAnalysis( 506 + likely_violations=[], 507 + likely_false_positives=[], 508 + needs_review=[], 509 + summary="", 510 + per_track_analysis={}, 511 + ) 512 + for batch in all_analyses: 513 + analysis.likely_violations.extend(batch.likely_violations) 514 + analysis.likely_false_positives.extend(batch.likely_false_positives) 515 + analysis.needs_review.extend(batch.needs_review) 516 + analysis.per_track_analysis.update(batch.per_track_analysis) 517 + 518 + # generate summary 519 + analysis.summary = ( 520 + f"analyzed {len(flags)} tracks: " 521 + f"{len(analysis.likely_violations)} likely violations, " 522 + f"{len(analysis.likely_false_positives)} likely false positives, " 523 + f"{len(analysis.needs_review)} need review" 524 + ) 525 + 526 + # debug: show raw counts 527 + console.print( 528 + f"[dim]raw analysis: {len(analysis.likely_violations)} violations, " 529 + f"{len(analysis.likely_false_positives)} false positives, " 530 + f"{len(analysis.needs_review)} needs review[/dim]" 531 + ) 532 + console.print( 533 + f"[dim]per_track_analysis entries: {len(analysis.per_track_analysis)}[/dim]" 534 + ) 535 + 536 + # display results 537 + display_analysis_summary(analysis, tracks_by_uri) 538 + 539 + if dry_run: 540 + console.print("\n[yellow][DRY RUN] no changes made[/yellow]") 541 + return 542 + 543 + # handle false positives 544 + if analysis.likely_false_positives: 545 + console.print() 546 + 547 + if auto_resolve: 548 + proceed = True 549 + console.print( 550 + "[yellow]auto-resolve enabled - proceeding without confirmation[/yellow]" 551 + ) 552 + else: 553 + proceed = Confirm.ask( 554 + f"resolve {len(analysis.likely_false_positives)} likely false positives?" 555 + ) 556 + 557 + if proceed: 558 + resolved = 0 559 + for uri in analysis.likely_false_positives: 560 + track_analysis = analysis.per_track_analysis.get(uri) 561 + reason = ( 562 + track_analysis.suggested_reason 563 + if track_analysis and track_analysis.suggested_reason 564 + else ResolutionReason.OTHER 565 + ) 566 + notes = ( 567 + f"AI analysis: {track_analysis.reasoning[:200]}" 568 + if track_analysis 569 + else "AI categorized as false positive" 570 + ) 571 + 572 + try: 573 + await client.resolve_flag(uri, reason, notes) 574 + resolved += 1 575 + console.print( 576 + f" [green]\u2713[/green] resolved: {uri[:60]}..." 577 + ) 578 + except Exception as e: 579 + console.print( 580 + f" [red]\u2717[/red] failed: {uri[:60]}... ({e})" 581 + ) 582 + 583 + console.print(f"\n[green]resolved {resolved} flags[/green]") 584 + 585 + finally: 586 + await client.close() 587 + 588 + 589 + def main() -> None: 590 + """main entry point.""" 591 + parser = argparse.ArgumentParser(description="AI moderation review agent") 592 + parser.add_argument( 593 + "--env", 594 + type=str, 595 + default="prod", 596 + choices=["dev", "staging", "prod"], 597 + help="environment (for display only)", 598 + ) 599 + parser.add_argument( 600 + "--dry-run", 601 + action="store_true", 602 + help="analyze flags without making changes", 603 + ) 604 + parser.add_argument( 605 + "--auto-resolve", 606 + action="store_true", 607 + help="resolve false positives without confirmation", 608 + ) 609 + parser.add_argument( 610 + "--limit", 611 + type=int, 612 + default=None, 613 + help="limit number of flags to process", 614 + ) 615 + 616 + args = parser.parse_args() 617 + 618 + asyncio.run(run_agent(args.env, args.dry_run, args.auto_resolve, args.limit)) 619 + 620 + 621 + if __name__ == "__main__": 622 + main()