feat: HTML email format, tab info hints, scan history
- Summary now renders as styled HTML (table layout, colored risk badge, warning banners) instead of plaintext in <div> - Tab info text explains scope: "Analysiert nur die eingegebene URL" vs "Scannt automatisch 5-10 Unterseiten" - Scan history with findings count badge and page count Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,9 @@ const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] =
|
|||||||
{ id: 'post_launch', label: 'Live-Website', desc: 'Bereits online analysieren', icon: '🌐' },
|
{ id: 'post_launch', label: 'Live-Website', desc: 'Bereits online analysieren', icon: '🌐' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
const TABS: { id: AnalysisTab; label: string; info: string }[] = [
|
||||||
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
|
{ id: 'quick', label: 'Schnellanalyse', info: 'Analysiert nur die eingegebene URL. Fuer einen umfassenden Check nutzen Sie den Website-Scan.' },
|
||||||
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
|
{ id: 'scan', label: 'Website-Scan', info: 'Scannt automatisch 5-10 Unterseiten (Startseite, Datenschutz, Impressum, AGB, Cookies) und gleicht erkannte Dienste mit der Datenschutzerklaerung ab.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
@@ -27,6 +27,7 @@ export default function AgentPage() {
|
|||||||
const [scanLoading, setScanLoading] = useState(false)
|
const [scanLoading, setScanLoading] = useState(false)
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
const [scanData, setScanData] = useState<any>(null)
|
const [scanData, setScanData] = useState<any>(null)
|
||||||
|
const [scanHistory, setScanHistory] = useState<any[]>([])
|
||||||
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -46,7 +47,9 @@ export default function AgentPage() {
|
|||||||
body: JSON.stringify({ url: url.trim(), mode }),
|
body: JSON.stringify({ url: url.trim(), mode }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Scan fehlgeschlagen: ${res.status}`)
|
if (!res.ok) throw new Error(`Scan fehlgeschlagen: ${res.status}`)
|
||||||
setScanData(await res.json())
|
const data = await res.json()
|
||||||
|
setScanData(data)
|
||||||
|
setScanHistory(prev => [{ url: url.trim(), ...data, scanned_at: new Date().toISOString() }, ...prev].slice(0, 20))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,6 +60,7 @@ export default function AgentPage() {
|
|||||||
|
|
||||||
const isLoading = tab === 'quick' ? loading : scanLoading
|
const isLoading = tab === 'quick' ? loading : scanLoading
|
||||||
const currentError = tab === 'quick' ? error : scanError
|
const currentError = tab === 'quick' ? error : scanError
|
||||||
|
const currentTab = TABS.find(t => t.id === tab)!
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
@@ -82,17 +86,20 @@ export default function AgentPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Selection */}
|
{/* Tab Selection + Info */}
|
||||||
<div className="flex border-b border-gray-200">
|
<div>
|
||||||
{TABS.map(t => (
|
<div className="flex border-b border-gray-200">
|
||||||
<button key={t.id} onClick={() => setTab(t.id)}
|
{TABS.map(t => (
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
<button key={t.id} onClick={() => setTab(t.id)}
|
||||||
tab === t.id
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
? 'border-purple-500 text-purple-700'
|
tab === t.id
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
? 'border-purple-500 text-purple-700'
|
||||||
{t.label}
|
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||||
</button>
|
{t.label}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2 px-1">{currentTab.info}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL Input */}
|
{/* URL Input */}
|
||||||
@@ -136,10 +143,32 @@ export default function AgentPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* History (quick only) */}
|
{/* History */}
|
||||||
{tab === 'quick' && (
|
{tab === 'quick' && (
|
||||||
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
|
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
|
||||||
)}
|
)}
|
||||||
|
{tab === 'scan' && scanHistory.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scanHistory.map((item, i) => (
|
||||||
|
<button key={i} onClick={() => { setUrl(item.url); }}
|
||||||
|
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-medium text-gray-500 w-8">{item.pages_scanned}p</span>
|
||||||
|
<span className="text-sm text-gray-700 truncate flex-1">{item.url}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${item.findings?.length > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{item.findings?.length || 0} Findings
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(item.scanned_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def analyze_url(req: AnalyzeRequest):
|
|||||||
email_result = send_email(
|
email_result = send_email(
|
||||||
recipient=req.recipient,
|
recipient=req.recipient,
|
||||||
subject=f"[{mode_label}] Compliance-Finding: {classification} — {req.url[:60]}",
|
subject=f"[{mode_label}] Compliance-Finding: {classification} — {req.url[:60]}",
|
||||||
body_html=f"<div>{summary}</div>",
|
body_html=summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AnalyzeResponse(
|
return AnalyzeResponse(
|
||||||
@@ -349,53 +349,77 @@ def _risk_to_escalation(risk_level: str) -> str:
|
|||||||
return mapping.get(risk_level.upper() if risk_level else "", "E0")
|
return mapping.get(risk_level.upper() if risk_level else "", "E0")
|
||||||
|
|
||||||
|
|
||||||
|
DOC_TYPE_LABELS = {
|
||||||
|
"privacy_policy": "Datenschutzerklaerung",
|
||||||
|
"cookie_banner": "Cookie-Banner",
|
||||||
|
"terms_of_service": "AGB",
|
||||||
|
"imprint": "Impressum",
|
||||||
|
"dpa": "Auftragsverarbeitung (AVV)",
|
||||||
|
"other": "Sonstiges",
|
||||||
|
}
|
||||||
|
|
||||||
|
RISK_COLORS = {
|
||||||
|
"MINIMAL": ("#16a34a", "Niedrig"),
|
||||||
|
"LOW": ("#ca8a04", "Gering"),
|
||||||
|
"LIMITED": ("#ea580c", "Mittel"),
|
||||||
|
"HIGH": ("#dc2626", "Hoch"),
|
||||||
|
"UNACCEPTABLE": ("#991b1b", "Kritisch"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(
|
def _build_summary(
|
||||||
url: str, classification: str, assessment: dict, role: str,
|
url: str, classification: str, assessment: dict, role: str,
|
||||||
findings_str: list[str], controls_str: list[str],
|
findings_str: list[str], controls_str: list[str],
|
||||||
mode: str = "post_launch",
|
mode: str = "post_launch",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a German manager summary, adapted to pre/post-launch context."""
|
"""Build HTML summary for email and frontend."""
|
||||||
risk = assessment.get("risk_level", "unbekannt")
|
risk = assessment.get("risk_level", "unbekannt")
|
||||||
score = assessment.get("risk_score", 0)
|
score = assessment.get("risk_score", 0)
|
||||||
recommendation = assessment.get("recommendation", "")
|
recommendation = assessment.get("recommendation", "")
|
||||||
dsfa = assessment.get("dsfa_recommended", False)
|
dsfa = assessment.get("dsfa_recommended", False)
|
||||||
is_live = mode == "post_launch"
|
is_live = mode == "post_launch"
|
||||||
|
risk_color, risk_label = RISK_COLORS.get(risk, ("#6b7280", risk))
|
||||||
|
doc_label = DOC_TYPE_LABELS.get(classification, classification)
|
||||||
|
|
||||||
findings_text = "\n".join(f"- {f}" for f in findings_str[:5]) if findings_str else "Keine"
|
mode_banner = (
|
||||||
controls_text = "\n".join(f"- {c}" for c in controls_str[:5]) if controls_str else "Keine"
|
'<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-bottom:16px;">'
|
||||||
|
'<strong style="color:#991b1b;">LIVE-WEBSITE</strong> — Das Dokument ist bereits oeffentlich zugaenglich.</div>'
|
||||||
mode_header = (
|
|
||||||
"PRUEFUNG LIVE-WEBSITE — Das Dokument ist bereits oeffentlich zugaenglich."
|
|
||||||
if is_live else
|
if is_live else
|
||||||
"INTERNE PRUEFUNG — Das Dokument ist noch nicht veroeffentlicht."
|
'<div style="background:#eff6ff;border-left:4px solid #3b82f6;padding:12px 16px;margin-bottom:16px;">'
|
||||||
|
'<strong style="color:#1e40af;">INTERNE PRUEFUNG</strong> — Dokument noch nicht veroeffentlicht.</div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
parts = [
|
findings_html = "".join(f'<li style="margin-bottom:4px;">{f}</li>' for f in findings_str[:8]) if findings_str else '<li style="color:#6b7280;">Keine</li>'
|
||||||
mode_header,
|
controls_html = "".join(f'<li style="margin-bottom:4px;">{c}</li>' for c in controls_str[:8]) if controls_str else '<li style="color:#6b7280;">Keine</li>'
|
||||||
"",
|
|
||||||
f"Dokumenttyp: {classification}",
|
|
||||||
f"Quelle: {url}",
|
|
||||||
f"Risikobewertung: {risk} ({score}/100)",
|
|
||||||
f"Zustaendig: {role}",
|
|
||||||
f"DSFA empfohlen: {'Ja' if dsfa else 'Nein'}",
|
|
||||||
"",
|
|
||||||
f"Findings:\n{findings_text}",
|
|
||||||
"",
|
|
||||||
f"Erforderliche Massnahmen:\n{controls_text}",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
warning = ""
|
||||||
if is_live and findings_str:
|
if is_live and findings_str:
|
||||||
parts.extend([
|
warning = (
|
||||||
"",
|
'<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px 16px;margin-top:16px;">'
|
||||||
"ACHTUNG: Diese Maengel sind bereits oeffentlich sichtbar. "
|
'<strong style="color:#dc2626;">⚠ ACHTUNG:</strong> Diese Maengel sind bereits oeffentlich sichtbar. '
|
||||||
"Sofortige Nachbesserung empfohlen um Abmahnrisiken zu minimieren.",
|
'Sofortige Nachbesserung empfohlen um Abmahnrisiken zu minimieren.</div>'
|
||||||
])
|
)
|
||||||
elif not is_live and controls_str:
|
elif not is_live and controls_str:
|
||||||
parts.extend([
|
warning = (
|
||||||
"",
|
'<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:12px 16px;margin-top:16px;">'
|
||||||
"Empfehlung: Implementieren Sie die erforderlichen Kontrollen vor der Veroeffentlichung.",
|
'Empfehlung: Implementieren Sie die erforderlichen Kontrollen vor der Veroeffentlichung.</div>'
|
||||||
])
|
)
|
||||||
|
|
||||||
if recommendation:
|
rec_html = f'<p style="color:#475569;margin-top:12px;"><em>{recommendation}</em></p>' if recommendation else ""
|
||||||
parts.extend(["", f"Weitere Empfehlung: {recommendation}"])
|
|
||||||
return "\n".join(parts)
|
return f"""
|
||||||
|
{mode_banner}
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;width:180px;">Dokumenttyp</td><td style="padding:6px 0;font-weight:600;">{doc_label}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Quelle</td><td style="padding:6px 0;"><a href="{url}" style="color:#6366f1;">{url}</a></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Risikobewertung</td><td style="padding:6px 0;"><span style="background:{risk_color};color:white;padding:2px 8px;border-radius:4px;font-size:13px;">{risk_label} ({score}/100)</span></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Zustaendig</td><td style="padding:6px 0;font-weight:600;">{role}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">DSFA empfohlen</td><td style="padding:6px 0;">{'Ja' if dsfa else 'Nein'}</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3 style="color:#1e293b;font-size:15px;margin:16px 0 8px;">Findings</h3>
|
||||||
|
<ul style="margin:0;padding-left:20px;color:#334155;">{findings_html}</ul>
|
||||||
|
<h3 style="color:#1e293b;font-size:15px;margin:16px 0 8px;">Erforderliche Massnahmen</h3>
|
||||||
|
<ul style="margin:0;padding-left:20px;color:#334155;">{controls_html}</ul>
|
||||||
|
{warning}
|
||||||
|
{rec_html}
|
||||||
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user