diff --git a/admin-compliance/app/sdk/agent/_components/FollowUpQuestions.tsx b/admin-compliance/app/sdk/agent/_components/FollowUpQuestions.tsx new file mode 100644 index 0000000..eb34d36 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/FollowUpQuestions.tsx @@ -0,0 +1,91 @@ +'use client' + +import React from 'react' +import type { FollowUpQuestion } from '../_hooks/useAgentAnalysis' + +const SEVERITY_STYLE: Record = { + high: { border: 'border-red-300', bg: 'bg-red-50', icon: '!!' }, + medium: { border: 'border-yellow-300', bg: 'bg-yellow-50', icon: '!' }, + low: { border: 'border-blue-300', bg: 'bg-blue-50', icon: 'i' }, +} + +interface Props { + questions: FollowUpQuestion[] + answers: Record + onAnswer: (questionId: string, answer: boolean) => void +} + +export function FollowUpQuestions({ questions, answers, onAnswer }: Props) { + const unanswered = questions.filter(q => answers[q.id] === undefined) + const answered = questions.filter(q => answers[q.id] !== undefined) + + if (questions.length === 0) return null + + return ( +
+

+ + + + Rueckfragen zur manuellen Pruefung ({unanswered.length} offen) +

+ + {/* Unanswered questions */} + {unanswered.map(q => { + const style = SEVERITY_STYLE[q.severity] || SEVERITY_STYLE.medium + return ( +
+
+ + {SEVERITY_STYLE[q.severity]?.icon || '?'} + +
+

{q.question}

+

Rechtsgrundlage: {q.legal_basis}

+
+ + +
+
+
+
+ ) + })} + + {/* Answered questions */} + {answered.map(q => { + const isYes = answers[q.id] + return ( +
+
+ + {isYes ? '✓' : '✗'} + + {q.question} + + {isYes ? 'Ja — OK' : 'Nein — Finding erstellt'} + +
+ {!isYes && ( +

{q.finding_if_no}

+ )} +
+ ) + })} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts b/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts index a9a3a19..2e01814 100644 --- a/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts +++ b/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts @@ -2,6 +2,14 @@ import { useState } from 'react' +export interface FollowUpQuestion { + id: string + question: string + legal_basis: string + severity: 'high' | 'medium' | 'low' + finding_if_no: string +} + export interface AnalysisResult { url: string classification: string @@ -14,6 +22,8 @@ export interface AnalysisResult { summary: string email_status: string analyzed_at: string + follow_up_questions: FollowUpQuestion[] + follow_up_answers: Record } const ESCALATION_ROLES: Record = { @@ -23,12 +33,6 @@ const ESCALATION_ROLES: Record = { E3: 'DSB + Rechtsabteilung', } -const SDK_HEADERS = { - 'Content-Type': 'application/json', - 'X-Tenant-ID': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', - 'X-User-ID': '00000000-0000-0000-0000-000000000001', -} - export function useAgentAnalysis() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -41,7 +45,6 @@ export function useAgentAnalysis() { setResult(null) try { - // Step 1: Fetch and classify const fetchRes = await fetch('/api/sdk/v1/agent/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -65,6 +68,8 @@ export function useAgentAnalysis() { summary: data.summary || '', email_status: data.email_status || 'pending', analyzed_at: new Date().toISOString(), + follow_up_questions: data.follow_up_questions || [], + follow_up_answers: {}, } setResult(analysisResult) @@ -76,5 +81,26 @@ export function useAgentAnalysis() { } } - return { analyze, loading, error, result, history } + function answerFollowUp(questionId: string, answer: boolean) { + if (!result) return + const question = result.follow_up_questions.find(q => q.id === questionId) + const newAnswers = { ...result.follow_up_answers, [questionId]: answer } + const newFindings = [...result.findings] + + // If user answered "no" → add the finding + if (!answer && question) { + newFindings.push(question.finding_if_no) + } + + const updated = { + ...result, + findings: newFindings, + follow_up_answers: newAnswers, + } + setResult(updated) + // Update history too + setHistory(prev => prev.map(h => h.analyzed_at === result.analyzed_at ? updated : h)) + } + + return { analyze, answerFollowUp, loading, error, result, history } } diff --git a/admin-compliance/app/sdk/agent/page.tsx b/admin-compliance/app/sdk/agent/page.tsx index c29fc6c..2b7f471 100644 --- a/admin-compliance/app/sdk/agent/page.tsx +++ b/admin-compliance/app/sdk/agent/page.tsx @@ -4,10 +4,11 @@ import React, { useState } from 'react' import { useAgentAnalysis } from './_hooks/useAgentAnalysis' import { AnalysisResult } from './_components/AnalysisResult' import { AnalysisHistory } from './_components/AnalysisHistory' +import { FollowUpQuestions } from './_components/FollowUpQuestions' export default function AgentPage() { const [url, setUrl] = useState('') - const { analyze, loading, error, result, history } = useAgentAnalysis() + const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis() const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -65,8 +66,19 @@ export default function AgentPage() { {/* Result */} {result && ( -
+
+ + {/* Follow-Up Questions */} + {result.follow_up_questions.length > 0 && ( +
+ +
+ )}
)} diff --git a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx index a384b30..e5545bd 100644 --- a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx +++ b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx @@ -54,6 +54,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side } label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} /> } label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} /> } label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} /> + } label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
{/* Payment / Terminal */} diff --git a/backend-compliance/compliance/api/agent_analyze_routes.py b/backend-compliance/compliance/api/agent_analyze_routes.py index 3198548..e36474d 100644 --- a/backend-compliance/compliance/api/agent_analyze_routes.py +++ b/backend-compliance/compliance/api/agent_analyze_routes.py @@ -43,6 +43,14 @@ class AnalyzeRequest(BaseModel): recipient: str = "dsb@breakpilot.local" +class FollowUpQuestion(BaseModel): + id: str + question: str + legal_basis: str + severity: str # "high", "medium", "low" + finding_if_no: str # Finding text if user answers "no" + + class AnalyzeResponse(BaseModel): url: str classification: str @@ -55,6 +63,7 @@ class AnalyzeResponse(BaseModel): summary: str email_status: str analyzed_at: str + follow_up_questions: list[FollowUpQuestion] = [] @router.post("/analyze", response_model=AnalyzeResponse) @@ -62,7 +71,7 @@ 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) + text, raw_html = await _fetch_and_clean(client, req.url) # Step 2: Classify via SDK LLM classification = await _classify(client, text) @@ -74,15 +83,23 @@ async def analyze_url(req: AnalyzeRequest): esc_level = assessment.get("escalation_level", "E0") role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"]) - # Step 5: Build summary + # Step 5: Website compliance checks (§312k BGB etc.) + site_findings, follow_ups = await _check_website_compliance(client, req.url, raw_html) + + # Step 6: Merge findings findings = assessment.get("triggered_rules", []) controls = assessment.get("required_controls", []) - # Convert for summary (use string lists, not raw dicts) - findings_str = _to_string_list(findings) + findings_str = _to_string_list(findings) + site_findings controls_str = _to_string_list(controls) + + # Escalate if website checks found issues + if site_findings and esc_level == "E0": + esc_level = "E1" + role = ESCALATION_ROLES["E1"] + summary = _build_summary(req.url, classification, assessment, role, findings_str, controls_str) - # Step 6: Send notification + # Step 7: Send notification email_result = send_email( recipient=req.recipient, subject=f"Compliance-Finding: {classification} — {req.url[:60]}", @@ -96,16 +113,17 @@ async def analyze_url(req: AnalyzeRequest): risk_score=assessment.get("risk_score", 0), escalation_level=esc_level, responsible_role=role, - findings=_to_string_list(findings), - required_controls=_to_string_list(controls), + findings=findings_str, + required_controls=controls_str, summary=summary, email_status=email_result.get("status", "failed"), analyzed_at=datetime.now(timezone.utc).isoformat(), + follow_up_questions=follow_ups, ) -async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str: - """Fetch URL and strip HTML to plain text.""" +async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> tuple[str, str]: + """Fetch URL. Returns (clean_text, raw_html).""" resp = await client.get(url, follow_redirects=True, headers={ "User-Agent": "BreakPilot-Compliance-Agent/1.0", }) @@ -115,7 +133,7 @@ async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str: clean = re.sub(r"<[^>]+>", " ", clean) clean = re.sub(r" ", " ", clean) clean = re.sub(r"\s+", " ", clean).strip() - return clean[:4000] + return clean[:4000], html async def _classify(client: httpx.AsyncClient, text: str) -> str: @@ -207,6 +225,103 @@ async def _assess(client: httpx.AsyncClient, text: str, classification: str) -> return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"} +async def _check_website_compliance( + client: httpx.AsyncClient, url: str, html: str, +) -> tuple[list[str], list[FollowUpQuestion]]: + """Scan public website for consumer protection compliance (§312k BGB etc.).""" + findings: list[str] = [] + follow_ups: list[FollowUpQuestion] = [] + html_lower = html.lower() + base_domain = re.sub(r"https?://([^/]+).*", r"\1", url) + + # --- §312k BGB: Kündigungsbutton --- + cancel_patterns = [ + r'href="[^"]*(?:kuendig|kündig|cancel|vertrag.?beenden|abo.?beenden|mitgliedschaft.?beenden)[^"]*"', + r'(?:kündigen|kuendigen|vertrag beenden|abo beenden|mitgliedschaft kündigen)', + ] + has_cancel_link = any(re.search(p, html_lower) for p in cancel_patterns) + + # Also check common cancel URLs + cancel_urls_to_probe = [ + f"https://{base_domain}/kuendigen", + f"https://{base_domain}/cancel", + f"https://{base_domain}/vertrag-kuendigen", + f"https://{base_domain}/abo-kuendigen", + f"https://{base_domain}/account/cancel", + ] + if not has_cancel_link: + for probe_url in cancel_urls_to_probe: + try: + probe = await client.head(probe_url, follow_redirects=True, timeout=5.0) + if probe.status_code < 400: + has_cancel_link = True + break + except Exception: + continue + + if not has_cancel_link: + findings.append( + "[§312k BGB] Kein oeffentlich sichtbarer Kuendigungsbutton gefunden. " + "Seit 01.07.2022 muessen online geschlossene Vertraege mit max. 2 Klicks kuendbar sein." + ) + follow_ups.append(FollowUpQuestion( + id="cancel_button_312k", + question="Koennen Sie nach Login im Kundenbereich innerhalb von 2 Klicks Ihren Vertrag kuendigen?", + legal_basis="§ 312k BGB (Kuendigungsbutton), Omnibus-Richtlinie (EU) 2019/2161", + severity="high", + finding_if_no=( + "[§312k BGB] VERSTOSS: Kein funktionaler Kuendigungsbutton vorhanden. " + "Der Anbieter ist verpflichtet, einen leicht auffindbaren Kuendigungsbutton " + "bereitzustellen (max. 2 Klicks). Ein Zwang zur telefonischen Kuendigung " + "oder Kuendigung per Brief ist rechtswidrig." + ), + )) + + # --- Impressumspflicht (§5 TMG / §18 MStV) --- + imprint_patterns = [ + r'href="[^"]*(?:impressum|imprint|legal.?notice|about.?us/legal)[^"]*"', + r'>impressum<', + ] + has_imprint = any(re.search(p, html_lower) for p in imprint_patterns) + if not has_imprint: + findings.append( + "[§5 TMG] Kein Impressum-Link auf der Seite gefunden. " + "Geschaeftsmaessige Online-Dienste muessen ein leicht erreichbares Impressum bereitstellen." + ) + + # --- Datenschutzerklaerung verlinkt? --- + privacy_patterns = [ + r'href="[^"]*(?:datenschutz|privacy|dsgvo)[^"]*"', + r'>datenschutz<', + ] + has_privacy = any(re.search(p, html_lower) for p in privacy_patterns) + if not has_privacy: + findings.append( + "[Art. 13 DSGVO] Kein Link zur Datenschutzerklaerung gefunden. " + "Nutzer muessen ueber die Verarbeitung personenbezogener Daten informiert werden." + ) + + # --- Cookie-Consent-Banner --- + cookie_patterns = [ + r'(?:cookie.?consent|cookie.?banner|consent.?manager|didomi|cookiebot|onetrust|usercentrics)', + r'(?:gdpr|dsgvo).?(?:consent|einwilligung)', + ] + has_cookie_consent = any(re.search(p, html_lower) for p in cookie_patterns) + if not has_cookie_consent: + follow_ups.append(FollowUpQuestion( + id="cookie_consent", + question="Wird beim ersten Besuch der Website ein Cookie-Consent-Banner angezeigt?", + legal_basis="§ 25 TDDDG (ehem. TTDSG), Art. 5(3) ePrivacy-Richtlinie", + severity="medium", + finding_if_no=( + "[§25 TDDDG] Kein Cookie-Consent-Banner erkannt. " + "Vor dem Setzen nicht-essentieller Cookies ist eine Einwilligung erforderlich." + ), + )) + + return findings, follow_ups + + def _to_string_list(items: list) -> list[str]: """Convert list of dicts or strings to list of strings.""" result = []