feat: ZeroClaw compliance agent — document analysis + role assignment + email
Add autonomous compliance agent that fetches web documents (cookie banners, privacy policies), classifies them via Qwen/Ollama, assesses DSGVO compliance, assigns to the responsible role, and sends notification emails. Components: - ZeroClaw SOP (6-step workflow: fetch, classify, assess, summarize, assign, notify) - Backend: /api/compliance/agent/analyze (combined endpoint) - Backend: /api/compliance/agent/notify (standalone email) - Frontend: /sdk/agent page (Manager UI with URL input + results) - Helper scripts + E2E test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
173
backend-compliance/compliance/api/agent_analyze_routes.py
Normal file
173
backend-compliance/compliance/api/agent_analyze_routes.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Agent Analyze Routes — combined endpoint that fetches a URL, classifies it,
|
||||
assesses DSGVO compliance, and sends a notification email.
|
||||
|
||||
POST /api/compliance/agent/analyze
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from compliance.services.smtp_sender import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
|
||||
|
||||
SDK_URL = os.environ.get("AI_SDK_URL", "http://ai-compliance-sdk:8093")
|
||||
TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
USER_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
ESCALATION_ROLES = {
|
||||
"E0": "Kein Handlungsbedarf",
|
||||
"E1": "Teamleitung Datenschutz",
|
||||
"E2": "Datenschutzbeauftragter (DSB)",
|
||||
"E3": "DSB + Rechtsabteilung",
|
||||
}
|
||||
|
||||
SDK_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Tenant-ID": TENANT_ID,
|
||||
"X-User-ID": USER_ID,
|
||||
}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
url: str
|
||||
recipient: str = "dsb@breakpilot.local"
|
||||
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
url: str
|
||||
classification: str
|
||||
risk_level: str
|
||||
risk_score: float
|
||||
escalation_level: str
|
||||
responsible_role: str
|
||||
findings: list[str]
|
||||
required_controls: list[str]
|
||||
summary: str
|
||||
email_status: str
|
||||
analyzed_at: str
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||
async def analyze_url(req: AnalyzeRequest):
|
||||
"""Fetch URL, classify, assess compliance, and notify responsible role."""
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
# Step 1: Fetch and clean
|
||||
text = await _fetch_and_clean(client, req.url)
|
||||
|
||||
# Step 2: Classify via SDK LLM
|
||||
classification = await _classify(client, text)
|
||||
|
||||
# Step 3: Assess via UCCA
|
||||
assessment = await _assess(client, text, classification)
|
||||
|
||||
# Step 4: Determine role
|
||||
esc_level = assessment.get("escalation_level", "E0")
|
||||
role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"])
|
||||
|
||||
# Step 5: Build summary
|
||||
findings = assessment.get("triggered_rules", [])
|
||||
controls = assessment.get("required_controls", [])
|
||||
summary = _build_summary(req.url, classification, assessment, role)
|
||||
|
||||
# Step 6: Send notification
|
||||
email_result = send_email(
|
||||
recipient=req.recipient,
|
||||
subject=f"Compliance-Finding: {classification} — {req.url[:60]}",
|
||||
body_html=f"<div>{summary}</div>",
|
||||
)
|
||||
|
||||
return AnalyzeResponse(
|
||||
url=req.url,
|
||||
classification=classification,
|
||||
risk_level=assessment.get("risk_level", "unknown"),
|
||||
risk_score=assessment.get("risk_score", 0),
|
||||
escalation_level=esc_level,
|
||||
responsible_role=role,
|
||||
findings=findings if isinstance(findings, list) else [str(findings)],
|
||||
required_controls=controls if isinstance(controls, list) else [str(controls)],
|
||||
summary=summary,
|
||||
email_status=email_result.get("status", "failed"),
|
||||
analyzed_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str:
|
||||
"""Fetch URL and strip HTML to plain text."""
|
||||
resp = await client.get(url, follow_redirects=True, headers={
|
||||
"User-Agent": "BreakPilot-Compliance-Agent/1.0",
|
||||
})
|
||||
html = resp.text
|
||||
# Strip script/style blocks, then all tags
|
||||
clean = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
clean = re.sub(r"<[^>]+>", " ", clean)
|
||||
clean = re.sub(r" ", " ", clean)
|
||||
clean = re.sub(r"\s+", " ", clean).strip()
|
||||
return clean[:4000]
|
||||
|
||||
|
||||
async def _classify(client: httpx.AsyncClient, text: str) -> str:
|
||||
"""Classify document type via SDK LLM chat."""
|
||||
try:
|
||||
resp = await client.post(f"{SDK_URL}/sdk/v1/llm/chat", headers=SDK_HEADERS, json={
|
||||
"messages": [
|
||||
{"role": "system", "content": (
|
||||
"Klassifiziere das Dokument in GENAU EINE Kategorie: "
|
||||
"privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. "
|
||||
"Antworte NUR mit dem Kategorienamen, nichts anderes."
|
||||
)},
|
||||
{"role": "user", "content": text[:2000]},
|
||||
],
|
||||
})
|
||||
data = resp.json()
|
||||
raw = data.get("response", data.get("content", "other")).strip().lower()
|
||||
for cat in ["privacy_policy", "cookie_banner", "terms_of_service", "imprint", "dpa"]:
|
||||
if cat in raw:
|
||||
return cat
|
||||
return "other"
|
||||
except Exception as e:
|
||||
logger.warning("Classification failed: %s", e)
|
||||
return "other"
|
||||
|
||||
|
||||
async def _assess(client: httpx.AsyncClient, text: str, classification: str) -> dict:
|
||||
"""Run UCCA assessment via SDK."""
|
||||
try:
|
||||
resp = await client.post(f"{SDK_URL}/sdk/v1/ucca/assess", headers=SDK_HEADERS, json={
|
||||
"use_case_text": text[:3000],
|
||||
"domain": classification,
|
||||
"data_categories": ["personal_data", "tracking", "cookies", "third_party_sharing"],
|
||||
})
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Assessment failed: %s", e)
|
||||
return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"}
|
||||
|
||||
|
||||
def _build_summary(url: str, classification: str, assessment: dict, role: str) -> str:
|
||||
"""Build a German manager summary."""
|
||||
risk = assessment.get("risk_level", "unbekannt")
|
||||
score = assessment.get("risk_score", 0)
|
||||
findings = assessment.get("triggered_rules", [])
|
||||
controls = assessment.get("required_controls", [])
|
||||
|
||||
findings_text = "\n".join(f"- {f}" for f in findings[:5]) if findings else "Keine"
|
||||
controls_text = "\n".join(f"- {c}" for c in controls[:5]) if controls else "Keine"
|
||||
|
||||
return (
|
||||
f"Dokumenttyp: {classification}\n"
|
||||
f"Quelle: {url}\n"
|
||||
f"Risikobewertung: {risk} ({score}/100)\n"
|
||||
f"Zustaendig: {role}\n\n"
|
||||
f"Findings:\n{findings_text}\n\n"
|
||||
f"Erforderliche Massnahmen:\n{controls_text}"
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Agent Notification Routes — endpoint for the ZeroClaw compliance agent
|
||||
to send notification emails via SMTP.
|
||||
|
||||
POST /api/compliance/agent/notify
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from compliance.services.smtp_sender import send_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/compliance/agent", tags=["agent"])
|
||||
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
recipient: EmailStr
|
||||
subject: str
|
||||
body_html: str
|
||||
role: str
|
||||
escalation_id: str | None = None
|
||||
|
||||
|
||||
class NotifyResponse(BaseModel):
|
||||
status: str
|
||||
recipient: str
|
||||
subject: str
|
||||
role: str
|
||||
sent_at: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@router.post("/notify", response_model=NotifyResponse)
|
||||
async def send_agent_notification(req: NotifyRequest):
|
||||
"""Send a compliance notification email on behalf of the agent."""
|
||||
result = send_email(
|
||||
recipient=req.recipient,
|
||||
subject=req.subject,
|
||||
body_html=_build_email_body(req),
|
||||
)
|
||||
|
||||
return NotifyResponse(
|
||||
status=result["status"],
|
||||
recipient=req.recipient,
|
||||
subject=req.subject,
|
||||
role=req.role,
|
||||
sent_at=datetime.now(timezone.utc).isoformat(),
|
||||
error=result.get("error"),
|
||||
)
|
||||
|
||||
|
||||
def _build_email_body(req: NotifyRequest) -> str:
|
||||
"""Wrap the agent's HTML body with a standard email frame."""
|
||||
return f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #1a1a2e; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="margin: 0; font-size: 18px;">BreakPilot Compliance Agent</h2>
|
||||
</div>
|
||||
<div style="padding: 24px; border: 1px solid #e2e8f0; border-top: none;">
|
||||
<p style="color: #64748b; font-size: 13px; margin-top: 0;">
|
||||
Zugewiesen an: <strong>{req.role}</strong>
|
||||
{f' | Eskalation: {req.escalation_id}' if req.escalation_id else ''}
|
||||
</p>
|
||||
{req.body_html}
|
||||
</div>
|
||||
<div style="background: #f8fafc; padding: 12px 24px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="color: #94a3b8; font-size: 11px; margin: 0;">
|
||||
Automatisch generiert vom BreakPilot Compliance Agent (ZeroClaw + Qwen)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
49
backend-compliance/compliance/services/smtp_sender.py
Normal file
49
backend-compliance/compliance/services/smtp_sender.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
SMTP Sender — sends real emails via SMTP (e.g., to Mailpit for dev).
|
||||
|
||||
Uses standard smtplib. Configuration via environment variables:
|
||||
SMTP_HOST (default: localhost)
|
||||
SMTP_PORT (default: 1025)
|
||||
SMTP_FROM_NAME (default: BreakPilot Compliance)
|
||||
SMTP_FROM_ADDR (default: compliance@breakpilot.local)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "localhost")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "1025"))
|
||||
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "BreakPilot Compliance")
|
||||
SMTP_FROM_ADDR = os.environ.get("SMTP_FROM_ADDR", "compliance@breakpilot.local")
|
||||
|
||||
|
||||
def send_email(
|
||||
recipient: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
from_addr: str | None = None,
|
||||
from_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an email via SMTP. Returns dict with status and message_id."""
|
||||
sender_addr = from_addr or SMTP_FROM_ADDR
|
||||
sender_name = from_name or SMTP_FROM_NAME
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{sender_name} <{sender_addr}>"
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:
|
||||
server.sendmail(sender_addr, [recipient], msg.as_string())
|
||||
logger.info("Email sent to %s: %s", recipient, subject)
|
||||
return {"status": "sent", "recipient": recipient, "subject": subject}
|
||||
except Exception as e:
|
||||
logger.error("Failed to send email to %s: %s", recipient, e)
|
||||
return {"status": "failed", "recipient": recipient, "error": str(e)}
|
||||
@@ -41,6 +41,10 @@ from compliance.api.screening_routes import router as screening_router
|
||||
# Company Profile
|
||||
from compliance.api.company_profile_routes import router as company_profile_router
|
||||
|
||||
# Agent (ZeroClaw compliance agent)
|
||||
from compliance.api.agent_notification_routes import router as agent_notify_router
|
||||
from compliance.api.agent_analyze_routes import router as agent_analyze_router
|
||||
|
||||
# Middleware
|
||||
from middleware import (
|
||||
RequestIDMiddleware,
|
||||
@@ -135,6 +139,10 @@ app.include_router(screening_router, prefix="/api")
|
||||
# Company Profile (CRUD with audit logging)
|
||||
app.include_router(company_profile_router, prefix="/api")
|
||||
|
||||
# Agent (ZeroClaw compliance agent → analyze + email via SMTP)
|
||||
app.include_router(agent_notify_router, prefix="/api")
|
||||
app.include_router(agent_analyze_router, prefix="/api")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
83
backend-compliance/tests/test_agent_notification_routes.py
Normal file
83
backend-compliance/tests/test_agent_notification_routes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for agent notification endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestAgentNotify:
|
||||
"""Tests for POST /api/compliance/agent/notify."""
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_success(self, mock_smtp, client):
|
||||
mock_instance = mock_smtp.return_value.__enter__.return_value
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
"subject": "Test Finding",
|
||||
"body_html": "<p>Test body</p>",
|
||||
"role": "Datenschutzbeauftragter",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "sent"
|
||||
assert data["recipient"] == "dsb@firma.de"
|
||||
assert data["role"] == "Datenschutzbeauftragter"
|
||||
assert data["sent_at"] is not None
|
||||
mock_instance.sendmail.assert_called_once()
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_with_escalation(self, mock_smtp, client):
|
||||
mock_smtp.return_value.__enter__.return_value
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "legal@firma.de",
|
||||
"subject": "Escalation E3",
|
||||
"body_html": "<h2>Urgent</h2>",
|
||||
"role": "DSB + Rechtsabteilung",
|
||||
"escalation_id": "esc-123",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "sent"
|
||||
assert data["role"] == "DSB + Rechtsabteilung"
|
||||
|
||||
def test_send_notification_invalid_email(self, client):
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "not-an-email",
|
||||
"subject": "Test",
|
||||
"body_html": "<p>Test</p>",
|
||||
"role": "DSB",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_send_notification_missing_fields(self, client):
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("compliance.services.smtp_sender.smtplib.SMTP")
|
||||
def test_send_notification_smtp_failure(self, mock_smtp, client):
|
||||
mock_smtp.return_value.__enter__.side_effect = ConnectionRefusedError("SMTP down")
|
||||
|
||||
resp = client.post("/api/compliance/agent/notify", json={
|
||||
"recipient": "dsb@firma.de",
|
||||
"subject": "Test",
|
||||
"body_html": "<p>Test</p>",
|
||||
"role": "DSB",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "SMTP down" in data["error"]
|
||||
Reference in New Issue
Block a user