Compare commits

..

3 Commits

Author SHA1 Message Date
Benjamin Admin 63bd6a7c6d feat: Compliance FAQ section in Agent page
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 3m17s
Build + Deploy / build-ai-sdk (push) Successful in 50s
Build + Deploy / build-developer-portal (push) Successful in 1m14s
Build + Deploy / build-tts (push) Successful in 1m27s
Build + Deploy / build-document-crawler (push) Successful in 42s
Build + Deploy / build-dsms-gateway (push) Successful in 24s
Build + Deploy / build-dsms-node (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 22s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m10s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 46s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 29s
CI / test-python-dsms-gateway (push) Successful in 24s
CI / validate-canonical-controls (push) Successful in 18s
Build + Deploy / trigger-orca (push) Successful in 2m15s
5 FAQ items covering:
- What happens when companies are sued (4 enforcement paths)
- How document checks work (3-step process)
- Which document types are checked (7 types, 138 checks)
- How reliable results are (0 false positives, LLM verification)
- What GDPR violations cost in practice (fine tiers + examples)

Includes EuGH rulings (C-300/21, C-319/20), CNIL fine examples,
and practical cost ranges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:32:07 +02:00
Benjamin Admin 6cec1dcdba perf: N+1 Query Fix — ListHazards 231x schneller
Ersetzt 231 einzelne DB-Queries durch 1 Batch-Query mit
DISTINCT ON (hazard_id) JOIN. Ladezeit von ~40s auf <1s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:28:15 +02:00
Benjamin Admin 136dc4d553 feat: Normen-Referenzen in Hazards + Massnahmen + Normenrecherche-Tab
- Hazard Log: Top 2 relevante Normen pro Kategorie unter dem Kategorie-Badge
- Massnahmen: Normen-Referenzen aus measures_library inline anzeigen
- Navigation: Neuer Normenrecherche-Tab (zwischen Grenzen und Komponenten)
- Normenrecherche-Seite: SuggestedNorms + A/B/C Erklaerung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 00:18:45 +02:00
8 changed files with 258 additions and 6 deletions
@@ -0,0 +1,112 @@
'use client'
import React, { useState } from 'react'
interface FAQItem {
q: string
a: string
}
const FAQ_ITEMS: FAQItem[] = [
{
q: "Was passiert wenn ein Unternehmen wegen unzureichender Datenschutzerklaerung oder Cookie-Richtlinie verklagt wird?",
a: `Es gibt vier Durchsetzungswege:
**1. Bussgelder durch Aufsichtsbehoerden (Art. 83 DSGVO)**
Aufsichtsbehoerden pruefen von Amts wegen oder auf Beschwerde — kein Klaeger noetig. Bussgelder bis 20 Mio. EUR oder 4% des Jahresumsatzes. Beispiele: CNIL gegen Google (150 Mio. EUR), Facebook (60 Mio. EUR), H&M (35 Mio. EUR). Auch KMU sind betroffen — der LfDI Baden-Wuerttemberg hat Bussgelder ab 10.000 EUR verhaengt.
**2. Abmahnungen durch Verbraucherschutzverbaende**
Verbaende wie vzbv oder DUH koennen ohne individuellen Schaden klagen (§2 UKlaG). Das ist der groesste praktische Druck: Unterlassungsklage + Anwaltskosten (5.000-20.000 EUR pro Fall). Seit EuGH C-319/20 (Meta/vzbv) duerfen Verbaende DSGVO-Verstoesse auch ohne Betroffenenauftrag klagen.
**3. Individueller Schadensersatz (Art. 82 DSGVO)**
Seit EuGH C-300/21 (Oesterreichische Post) genuegt bereits der "Kontrollverlust" ueber Daten als immaterieller Schaden — kein messbarer finanzieller Schaden noetig. Typisch: 100-5.000 EUR pro Betroffenem. Legaltech-Firmen wie NOYB buendeln Massenverfahren.
**4. Wettbewerber-Abmahnungen (UWG)**
Seit 2021 eingeschraenkt, aber Impressums-Maengel oder fehlende Cookie-Einwilligung bleiben abmahnfaehig.
Die Aufsichtsbehoerden erhalten ueber 10.000 Beschwerden pro Jahr. Eine Beschwerde einzureichen ist kostenlos und mit einem Klick moeglich.`,
},
{
q: "Wie funktioniert die Dokumentenpruefung?",
a: `Die Pruefung laeuft in drei Schritten:
**1. Text-Extraktion** — Playwright laedt die Seite, expandiert Accordions/Tabs und extrahiert den vollstaendigen Text.
**2. Regex-Checks (138 Pruefpunkte)** — Zwei Ebenen: L1 prueft ob Pflichtangaben erwaehnt sind (z.B. "Verantwortlicher"), L2 prueft ob sie korrekt und vollstaendig sind (z.B. "Hat der Verantwortliche eine ladungsfaehige Anschrift mit PLZ?").
**3. LLM-Verifikation** — Jeder fehlgeschlagene Check wird von einem KI-Modell (Qwen) gegen den Originaltext gegengeprueft, um Fehlalarme zu eliminieren.
Das Ergebnis: Zwei Scores pro Dokument — Vollstaendigkeit (sind alle Pflichtangaben da?) und Korrektheit (sind sie richtig formuliert?). Jeder fehlende Punkt hat eine konkrete Handlungsanweisung mit Rechtsbezug.`,
},
{
q: "Welche Dokumenttypen werden geprueft?",
a: `Sieben Dokumenttypen mit jeweils eigener Checkliste:
- **Datenschutzinformation (DSI)** — Art. 13/14 DSGVO (31 Checks)
- **Cookie-Richtlinie** — §25 TDDDG (15 Checks)
- **Impressum** — §5 TMG / §18 MStV (16 Checks)
- **AGB** — §305ff BGB (21 Checks)
- **Widerrufsbelehrung** — §355 BGB (15 Checks)
- **Social Media DSE** — Art. 26 DSGVO Joint Controller (20 Checks)
- **DSFA** — Art. 35 DSGVO (18 Checks)
Sub-Sektionen (z.B. Cookie-Abschnitt innerhalb der DSI) werden automatisch erkannt und separat geprueft.`,
},
{
q: "Wie zuverlaessig sind die Ergebnisse?",
a: `Die Pruefung wurde gegen mehrere Ground-Truth-Websites validiert (IHK Konstanz, ETO Gruppe, BMW, Stadt Koeln, Sparkasse, Spiegel u.a.). Ergebnis: **0 False Positives** bei validierten Testfaellen — jeder rote Punkt ist ein echtes Finding.
Durch die LLM-Verifikation werden Regex-Fehlalarme (z.B. durch ungewoehnliche Formatierung oder Soft Hyphens im HTML) automatisch korrigiert. Trotzdem gilt: Das Tool ersetzt keine Rechtsberatung, sondern identifiziert Handlungsbedarf.`,
},
{
q: "Was kostet ein Verstoss gegen die DSGVO in der Praxis?",
a: `Bussgelder nach Art. 83 DSGVO staffeln sich in zwei Stufen:
- **Bis 10 Mio. EUR / 2% Umsatz**: Verstoesse gegen technische/organisatorische Pflichten (Art. 25, 28, 32)
- **Bis 20 Mio. EUR / 4% Umsatz**: Verstoesse gegen Grundsaetze, Betroffenenrechte, Drittlandtransfer
Typische Praxis-Bussgelder in Deutschland: 5.000-50.000 EUR fuer KMU, 100.000-1 Mio. EUR fuer groessere Unternehmen. Dazu kommen Anwaltskosten bei Abmahnungen (5.000-20.000 EUR pro Fall) und Reputationsschaden.`,
},
]
export function ComplianceFAQ() {
const [open, setOpen] = useState<number | null>(null)
return (
<div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-800">Haeufige Fragen</h3>
</div>
<div className="divide-y divide-gray-100">
{FAQ_ITEMS.map((item, i) => (
<div key={i}>
<button
onClick={() => setOpen(open === i ? null : i)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors"
>
<span className="text-sm font-medium text-gray-900 pr-4">{item.q}</span>
<svg
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${open === i ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open === i && (
<div className="px-4 pb-4 text-sm text-gray-600 prose prose-sm max-w-none">
{item.a.split('\n\n').map((para, pi) => (
<p key={pi} className="mb-2 last:mb-0" dangerouslySetInnerHTML={{
__html: para
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n- /g, '<br/>• ')
.replace(/\n/g, '<br/>')
}} />
))}
</div>
)}
</div>
))}
</div>
</div>
)
}
+3
View File
@@ -8,6 +8,7 @@ import { FollowUpQuestions } from './_components/FollowUpQuestions'
import { ScanResult } from './_components/ScanResult'
import { DocCheckTab } from './_components/DocCheckTab'
import { BannerCheckTab } from './_components/BannerCheckTab'
import { ComplianceFAQ } from './_components/ComplianceFAQ'
type AnalysisMode = 'pre_launch' | 'post_launch'
type AnalysisTab = 'quick' | 'scan' | 'doc-check' | 'banner-check'
@@ -311,6 +312,8 @@ export default function AgentPage() {
</div>
</div>
)}
{/* FAQ */}
<ComplianceFAQ />
</div>
)
}
@@ -80,6 +80,25 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
const [mitCounts, setMitCounts] = useState<Record<string, number>>({})
const [edits, setEdits] = useState<Record<string, EditState>>({})
const [saving, setSaving] = useState<string | null>(null)
const [normsByCategory, setNormsByCategory] = useState<Record<string, string[]>>({})
// Fetch norms library and build category→norm-numbers map
useEffect(() => {
fetch(`/api/sdk/v1/iace/norms-library`)
.then(r => r.ok ? r.json() : null)
.then(json => {
if (!json?.norms) return
const map: Record<string, string[]> = {}
for (const n of json.norms) {
for (const cat of (n.hazard_cats || [])) {
if (!map[cat]) map[cat] = []
if (map[cat].length < 3) map[cat].push(n.number)
}
}
setNormsByCategory(map)
})
.catch(() => {})
}, [])
// Fetch mitigation counts per hazard
useEffect(() => {
@@ -226,6 +245,11 @@ export function RiskAssessmentTable({ projectId, hazards, onReassess, decisions,
<span className="inline-block px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-[10px] font-medium">
{CATEGORY_LABELS[h.category] || h.category}
</span>
{normsByCategory[h.category]?.length > 0 && (
<div className="text-[9px] text-blue-500 mt-0.5 truncate" title={normsByCategory[h.category].join(', ')}>
{normsByCategory[h.category].slice(0, 2).join(', ')}
</div>
)}
</td>
{/* Initial S/E/P/RPZ/Risk */}
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{initS}</td>
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { REDUCTION_TYPES, Mitigation } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
@@ -21,6 +21,24 @@ export default function MitigationsPage() {
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
} = useMitigations(projectId)
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
useEffect(() => {
fetch('/api/sdk/v1/iace/protective-measures-library')
.then(r => r.ok ? r.json() : null)
.then(json => {
if (!json?.protective_measures) return
const map: Record<string, string[]> = {}
for (const m of json.protective_measures) {
if (m.norm_references?.length > 0) {
map[(m.name || '').toLowerCase()] = m.norm_references
}
}
setMeasureNorms(map)
})
.catch(() => {})
}, [])
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
const [showLibrary, setShowLibrary] = useState(false)
@@ -195,6 +213,12 @@ export default function MitigationsPage() {
<div className="min-w-0">
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
{m.description && <div className="text-xs text-gray-400 mt-0.5">{m.description}</div>}
{(() => {
const refs = measureNorms[(m.title || '').toLowerCase()]
return refs?.length > 0 ? (
<div className="text-[9px] text-blue-500 mt-0.5">Normen: {refs.join(', ')}</div>
) : null
})()}
</div>
<div className="text-xs text-gray-500">
{(m.linked_hazard_names || []).join(', ') || '-'}
@@ -0,0 +1,39 @@
'use client'
import { useParams } from 'next/navigation'
import { SuggestedNorms } from '../_components/SuggestedNorms'
export default function NormsPage() {
const params = useParams()
const projectId = params.projectId as string
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Normenrecherche</h1>
<p className="text-sm text-gray-500 mt-1">
Relevante Normen fuer Ihr Produkt, automatisch ermittelt aus Maschinentyp, Gefaehrdungen
und Komponenten. Ergaenzen Sie bei Bedarf weitere Normen manuell.
</p>
</div>
{/* Info banner */}
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-xs text-blue-800 dark:text-blue-300">
<strong>A-Normen</strong> (z.B. ISO 12100) gelten fuer alle Maschinen.{' '}
<strong>B-Normen</strong> decken Sicherheitsaspekte ab (B1: Grundnormen, B2: Schutzeinrichtungen).{' '}
<strong>C-Normen</strong> sind maschinenspezifisch und erzeugen eine Konformitaetsvermutung.
</div>
</div>
</div>
{/* Suggested norms component — rendered expanded (not collapsed by default) */}
<SuggestedNorms projectId={projectId} />
</div>
)
}
+7
View File
@@ -9,6 +9,7 @@ const IACE_NAV_ITEMS = [
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
{ id: 'norms', label: 'Normenrecherche', href: '/norms', icon: 'book' },
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
@@ -91,6 +92,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)
case 'book':
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)
default:
return null
}
@@ -148,19 +148,19 @@ func (h *IACEHandler) ListHazards(c *gin.Context) {
hazards = []iace.Hazard{}
}
// Enrich hazards with latest risk assessment
// Enrich hazards with latest risk assessment (single batch query instead of N+1)
type enrichedHazard struct {
iace.Hazard
RiskAssessment interface{} `json:"risk_assessment"`
}
assessmentMap, _ := h.store.GetLatestAssessmentsByProject(c.Request.Context(), projectID)
enriched := make([]enrichedHazard, len(hazards))
for i, hz := range hazards {
enriched[i] = enrichedHazard{Hazard: hz}
// Get latest assessment for this hazard
assessments, err := h.store.ListAssessments(c.Request.Context(), hz.ID)
if err == nil && len(assessments) > 0 {
enriched[i].RiskAssessment = assessments[len(assessments)-1]
if ra, ok := assessmentMap[hz.ID]; ok {
enriched[i].RiskAssessment = ra
}
}
@@ -302,6 +302,49 @@ func (s *Store) ListAssessments(ctx context.Context, hazardID uuid.UUID) ([]Risk
return assessments, nil
}
// GetLatestAssessmentsByProject fetches the latest risk assessment for ALL hazards
// of a project in a single query. Returns map[hazardID]RiskAssessment.
func (s *Store) GetLatestAssessmentsByProject(ctx context.Context, projectID uuid.UUID) (map[uuid.UUID]RiskAssessment, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (ra.hazard_id)
ra.id, ra.hazard_id, ra.version, ra.assessment_type,
ra.severity, ra.exposure, ra.probability,
ra.inherent_risk, ra.control_maturity, ra.control_coverage,
ra.test_evidence_strength, ra.c_eff, ra.residual_risk,
ra.risk_level, ra.is_acceptable, ra.acceptance_justification,
ra.assessed_by, ra.created_at
FROM iace_risk_assessments ra
JOIN iace_hazards h ON h.id = ra.hazard_id
WHERE h.project_id = $1
ORDER BY ra.hazard_id, ra.version DESC, ra.created_at DESC
`, projectID)
if err != nil {
return nil, fmt.Errorf("get latest assessments by project: %w", err)
}
defer rows.Close()
result := make(map[uuid.UUID]RiskAssessment)
for rows.Next() {
var a RiskAssessment
var assessmentType, riskLevel string
err := rows.Scan(
&a.ID, &a.HazardID, &a.Version, &assessmentType,
&a.Severity, &a.Exposure, &a.Probability,
&a.InherentRisk, &a.ControlMaturity, &a.ControlCoverage,
&a.TestEvidenceStrength, &a.CEff, &a.ResidualRisk,
&riskLevel, &a.IsAcceptable, &a.AcceptanceJustification,
&a.AssessedBy, &a.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("scan assessment: %w", err)
}
a.AssessmentType = AssessmentType(assessmentType)
a.RiskLevel = RiskLevel(riskLevel)
result[a.HazardID] = a
}
return result, nil
}
// ============================================================================
// Risk Summary (Aggregated View)
// ============================================================================