diff --git a/admin-compliance/app/api/sdk/v1/regulatory-news/route.ts b/admin-compliance/app/api/sdk/v1/regulatory-news/route.ts new file mode 100644 index 0000000..f405688 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/regulatory-news/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' + +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' +const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const qs = searchParams.toString() + const url = `${SDK_URL}/sdk/v1/regulatory-news${qs ? `?${qs}` : ''}` + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID, + }, + }) + + if (!response.ok) { + return NextResponse.json({ error: 'SDK error' }, { status: response.status }) + } + return NextResponse.json(await response.json()) + } catch { + return NextResponse.json({ error: 'Connection failed' }, { status: 503 }) + } +} diff --git a/admin-compliance/app/sdk/page.tsx b/admin-compliance/app/sdk/page.tsx index f252461..f8c1ec4 100644 --- a/admin-compliance/app/sdk/page.tsx +++ b/admin-compliance/app/sdk/page.tsx @@ -4,6 +4,7 @@ import React from 'react' import Link from 'next/link' import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk' import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector' +import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed' import type { SDKPackageId } from '@/lib/sdk/types' // ============================================================================= @@ -331,6 +332,9 @@ export default function SDKDashboard() { )} + {/* Regulatory News */} + + {/* 5 Packages */}

Compliance-Pakete

diff --git a/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsCard.tsx b/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsCard.tsx new file mode 100644 index 0000000..80f6f4e --- /dev/null +++ b/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsCard.tsx @@ -0,0 +1,60 @@ +'use client' + +import Link from 'next/link' + +export interface RegulatoryNewsItemData { + id: string + headline: string + summary: string + legal_reference: string + deadline: string + days_remaining: number + urgency: 'critical' | 'high' | 'medium' | 'low' + affected: string + action_required: string + action_link: string + regulation: string + sanctions?: string +} + +const URGENCY_STYLES = { + critical: { badge: 'bg-red-100 text-red-700 border-red-200', border: 'border-l-red-500', icon: '๐Ÿ”ด' }, + high: { badge: 'bg-orange-100 text-orange-700 border-orange-200', border: 'border-l-orange-400', icon: '๐ŸŸ ' }, + medium: { badge: 'bg-yellow-100 text-yellow-700 border-yellow-200', border: 'border-l-yellow-400', icon: '๐ŸŸก' }, + low: { badge: 'bg-gray-100 text-gray-600 border-gray-200', border: 'border-l-gray-300', icon: '๐Ÿ”ต' }, +} + +export function RegulatoryNewsCard({ item }: { item: RegulatoryNewsItemData }) { + const style = URGENCY_STYLES[item.urgency] || URGENCY_STYLES.low + + return ( +
+
+
+

{item.headline}

+

{item.summary}

+

{item.legal_reference}

+ {item.sanctions && ( +

Sanktionen: {item.sanctions}

+ )} +
+
+ + {style.icon} {item.days_remaining} Tage + + + {new Date(item.deadline).toLocaleDateString('de-DE')} + +
+
+
+ {item.affected} + {item.action_link && ( + + Massnahme ergreifen โ†’ + + )} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsFeed.tsx b/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsFeed.tsx new file mode 100644 index 0000000..3ad9fd3 --- /dev/null +++ b/admin-compliance/components/sdk/regulatory-news/RegulatoryNewsFeed.tsx @@ -0,0 +1,54 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { RegulatoryNewsCard, RegulatoryNewsItemData } from './RegulatoryNewsCard' + +interface RegulatoryNewsFeedProps { + businessModel?: string + maxItems?: number +} + +export function RegulatoryNewsFeed({ businessModel, maxItems = 3 }: RegulatoryNewsFeedProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [showAll, setShowAll] = useState(false) + + useEffect(() => { + const params = new URLSearchParams({ limit: '10', horizon_days: '365' }) + if (businessModel) params.set('business_model', businessModel) + + fetch(`/api/sdk/v1/regulatory-news?${params}`) + .then(r => r.ok ? r.json() : { items: [] }) + .then(data => setItems(data.items || [])) + .catch(() => setItems([])) + .finally(() => setLoading(false)) + }, [businessModel]) + + if (loading) return null + if (items.length === 0) return null + + const visible = showAll ? items : items.slice(0, maxItems) + + return ( +
+
+

+ ๐Ÿ“ข Regulatorische Neuigkeiten +

+ {items.length > maxItems && ( + + )} +
+
+ {visible.map(item => ( + + ))} +
+
+ ) +} diff --git a/ai-compliance-sdk/internal/api/handlers/regulatory_news_handlers.go b/ai-compliance-sdk/internal/api/handlers/regulatory_news_handlers.go new file mode 100644 index 0000000..3cda7d8 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/regulatory_news_handlers.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" + "github.com/gin-gonic/gin" +) + +// RegulatoryNewsHandlers serves regulatory news from obligation v2 data. +type RegulatoryNewsHandlers struct { + regulations map[string]*ucca.V2RegulationFile +} + +// NewRegulatoryNewsHandlers creates a handler backed by pre-loaded regulation data. +func NewRegulatoryNewsHandlers(regs map[string]*ucca.V2RegulationFile) *RegulatoryNewsHandlers { + return &RegulatoryNewsHandlers{regulations: regs} +} + +// GetNews returns upcoming regulatory deadlines sorted by urgency. +func (h *RegulatoryNewsHandlers) GetNews(c *gin.Context) { + filter := ucca.RegulatoryNewsFilter{ + BusinessModel: c.Query("business_model"), + HorizonDays: parseIntOrDefault(c.Query("horizon_days"), 365), + Limit: parseIntOrDefault(c.Query("limit"), 5), + } + + items := ucca.GetRegulatoryNews(h.regulations, filter) + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) +} + +func parseIntOrDefault(s string, def int) int { + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil { + return def + } + return v +} diff --git a/ai-compliance-sdk/internal/app/app.go b/ai-compliance-sdk/internal/app/app.go index 1d29112..7474762 100644 --- a/ai-compliance-sdk/internal/app/app.go +++ b/ai-compliance-sdk/internal/app/app.go @@ -134,6 +134,14 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine { ragHandlers := handlers.NewRAGHandlers(corpusVersionStore) obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore) + // Regulatory News + allV2Regs, err := ucca.LoadAllV2Regulations() + if err != nil { + log.Printf("WARNING: V2 regulations not loaded: %v", err) + allV2Regs = make(map[string]*ucca.V2RegulationFile) + } + regulatoryNewsHandlers := handlers.NewRegulatoryNewsHandlers(allV2Regs) + // Maximizer maximizerStore := maximizer.NewStore(pool) maximizerRules, err := maximizer.LoadConstraintRulesFromDefault() @@ -168,7 +176,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine { uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers, roadmapHandlers, workshopHandlers, portfolioHandlers, academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler, - maximizerHandlers) + maximizerHandlers, regulatoryNewsHandlers) return router } diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index 6319cea..492c85c 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -27,6 +27,7 @@ func registerRoutes( whistleblowerHandlers *handlers.WhistleblowerHandlers, iaceHandler *handlers.IACEHandler, maximizerHandlers *handlers.MaximizerHandlers, + regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers, ) { v1 := router.Group("/sdk/v1") { @@ -48,6 +49,7 @@ func registerRoutes( registerWhistleblowerRoutes(v1, whistleblowerHandlers) registerIACERoutes(v1, iaceHandler) registerMaximizerRoutes(v1, maximizerHandlers) + v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews) } } diff --git a/ai-compliance-sdk/internal/ucca/regulatory_news.go b/ai-compliance-sdk/internal/ucca/regulatory_news.go new file mode 100644 index 0000000..b3247a9 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/regulatory_news.go @@ -0,0 +1,146 @@ +package ucca + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// RegulatoryNewsItem is a single news item for dashboard display. +type RegulatoryNewsItem struct { + ID string `json:"id"` + Headline string `json:"headline"` + Summary string `json:"summary"` + LegalReference string `json:"legal_reference"` + Deadline string `json:"deadline"` + DaysRemaining int `json:"days_remaining"` + Urgency string `json:"urgency"` // critical, high, medium, low + Affected string `json:"affected"` + ActionRequired string `json:"action_required"` + ActionLink string `json:"action_link"` + Regulation string `json:"regulation"` + Sanctions string `json:"sanctions,omitempty"` +} + +// RegulatoryNewsFilter controls which news items are returned. +type RegulatoryNewsFilter struct { + BusinessModel string `json:"business_model,omitempty"` + HorizonDays int `json:"horizon_days,omitempty"` // default 365 + Limit int `json:"limit,omitempty"` // default 5 +} + +// GetRegulatoryNews scans all v2 obligations for upcoming deadlines +// and returns formatted news items sorted by urgency. +func GetRegulatoryNews(regulations map[string]*V2RegulationFile, filter RegulatoryNewsFilter) []RegulatoryNewsItem { + if filter.HorizonDays <= 0 { + filter.HorizonDays = 365 + } + if filter.Limit <= 0 { + filter.Limit = 5 + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + horizon := today.AddDate(0, 0, filter.HorizonDays) + var items []RegulatoryNewsItem + + for _, reg := range regulations { + for _, obl := range reg.Obligations { + deadline, ok := resolveDeadline(obl) + if !ok || deadline.Before(today) || deadline.After(horizon) { + continue + } + + days := int(deadline.Sub(today).Hours() / 24) + item := buildNewsItem(obl, reg.Regulation, deadline, days) + items = append(items, item) + } + } + + sort.Slice(items, func(i, j int) bool { + return items[i].DaysRemaining < items[j].DaysRemaining + }) + + if len(items) > filter.Limit { + items = items[:filter.Limit] + } + return items +} + +func buildNewsItem(obl V2Obligation, regulation string, deadline time.Time, days int) RegulatoryNewsItem { + item := RegulatoryNewsItem{ + ID: obl.ID, + Deadline: deadline.Format("2006-01-02"), + DaysRemaining: days, + Urgency: computeUrgency(days), + Regulation: regulation, + } + + // Use hand-crafted news if available + if obl.News != nil { + item.Headline = obl.News.Headline + item.Summary = obl.News.Summary + item.ActionRequired = obl.News.ActionRequired + item.Affected = obl.News.Affected + item.ActionLink = obl.News.ActionLink + } else { + // Auto-generate from obligation data + item.Headline = fmt.Sprintf("%s โ€” ab %s", obl.Title, deadline.Format("02.01.2006")) + item.Summary = obl.Description + item.ActionRequired = "Pruefen Sie die Anforderungen und ergreifen Sie Massnahmen." + item.ActionLink = obl.BreakpilotFeature + } + + item.LegalReference = formatLegalReference(obl.LegalBasis) + + if obl.Sanctions != nil { + item.Sanctions = obl.Sanctions.MaxFine + } + return item +} + +func resolveDeadline(obl V2Obligation) (time.Time, bool) { + // Check explicit deadline.date first + if obl.Deadline != nil && obl.Deadline.Date != "" { + t, err := time.Parse("2006-01-02", obl.Deadline.Date) + if err == nil { + return t, true + } + } + // Fallback to valid_from + if obl.ValidFrom != "" { + t, err := time.Parse("2006-01-02", obl.ValidFrom) + if err == nil { + return t, true + } + } + return time.Time{}, false +} + +func computeUrgency(daysRemaining int) string { + switch { + case daysRemaining <= 30: + return "critical" + case daysRemaining <= 90: + return "high" + case daysRemaining <= 180: + return "medium" + default: + return "low" + } +} + +func formatLegalReference(bases []V2LegalBasis) string { + if len(bases) == 0 { + return "" + } + var refs []string + for _, b := range bases { + ref := b.Article + if b.Norm != "" { + ref = b.Article + " " + b.Norm + } + refs = append(refs, ref) + } + return strings.Join(refs, ", ") +} diff --git a/ai-compliance-sdk/internal/ucca/regulatory_news_test.go b/ai-compliance-sdk/internal/ucca/regulatory_news_test.go new file mode 100644 index 0000000..0501583 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/regulatory_news_test.go @@ -0,0 +1,191 @@ +package ucca + +import ( + "testing" + "time" +) + +func makeTestRegulations() map[string]*V2RegulationFile { + future30 := time.Now().AddDate(0, 0, 30).Format("2006-01-02") + future90 := time.Now().AddDate(0, 0, 90).Format("2006-01-02") + past := time.Now().AddDate(0, 0, -10).Format("2006-01-02") + + return map[string]*V2RegulationFile{ + "TestReg": { + Regulation: "TestReg", + Obligations: []V2Obligation{ + { + ID: "TR-001", Title: "Upcoming Critical", + Deadline: &V2Deadline{Date: future30}, + News: &V2ObligationNews{ + Headline: "Critical Deadline", Summary: "Test summary", + ActionRequired: "Do something", Affected: "All", ActionLink: "/sdk/test", + }, + LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 1"}}, + }, + { + ID: "TR-002", Title: "Upcoming Medium", + Description: "Medium priority regulation change.", + Deadline: &V2Deadline{Date: future90}, + LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 2"}}, + }, + { + ID: "TR-003", Title: "Past Deadline", + Deadline: &V2Deadline{Date: past}, + }, + { + ID: "TR-004", Title: "No Deadline", + }, + }, + }, + } +} + +func TestGetRegulatoryNews_SortedByUrgency(t *testing.T) { + regs := makeTestRegulations() + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10}) + + if len(items) != 2 { + t.Fatalf("expected 2 items (future only), got %d", len(items)) + } + // First item should be the more urgent one (30 days) + if items[0].ID != "TR-001" { + t.Errorf("expected TR-001 first (most urgent), got %s", items[0].ID) + } + if items[1].ID != "TR-002" { + t.Errorf("expected TR-002 second, got %s", items[1].ID) + } +} + +func TestGetRegulatoryNews_UsesNewsField(t *testing.T) { + regs := makeTestRegulations() + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10}) + + // TR-001 has hand-crafted news + if items[0].Headline != "Critical Deadline" { + t.Errorf("expected hand-crafted headline, got %q", items[0].Headline) + } + if items[0].ActionLink != "/sdk/test" { + t.Errorf("expected /sdk/test, got %q", items[0].ActionLink) + } +} + +func TestGetRegulatoryNews_AutoGeneratesWithoutNews(t *testing.T) { + regs := makeTestRegulations() + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10}) + + // TR-002 has no news field โ€” should auto-generate + if items[1].Headline == "" { + t.Error("expected auto-generated headline") + } + if items[1].Summary == "" { + t.Error("expected auto-generated summary from description") + } +} + +func TestGetRegulatoryNews_ExcludesPastDeadlines(t *testing.T) { + regs := makeTestRegulations() + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10}) + + for _, item := range items { + if item.ID == "TR-003" { + t.Error("past deadline should be excluded") + } + if item.ID == "TR-004" { + t.Error("no-deadline obligation should be excluded") + } + } +} + +func TestGetRegulatoryNews_LimitWorks(t *testing.T) { + regs := makeTestRegulations() + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 1}) + + if len(items) != 1 { + t.Errorf("expected 1 item with limit=1, got %d", len(items)) + } +} + +func TestComputeUrgency(t *testing.T) { + tests := []struct { + days int + expected string + }{ + {5, "critical"}, + {30, "critical"}, + {31, "high"}, + {90, "high"}, + {91, "medium"}, + {180, "medium"}, + {181, "low"}, + {365, "low"}, + } + for _, tc := range tests { + got := computeUrgency(tc.days) + if got != tc.expected { + t.Errorf("computeUrgency(%d) = %q, want %q", tc.days, got, tc.expected) + } + } +} + +func TestResolveDeadline_DeadlineDate(t *testing.T) { + obl := V2Obligation{Deadline: &V2Deadline{Date: "2026-06-19"}} + d, ok := resolveDeadline(obl) + if !ok { + t.Fatal("expected deadline resolved") + } + if d.Format("2006-01-02") != "2026-06-19" { + t.Errorf("got %s", d.Format("2006-01-02")) + } +} + +func TestResolveDeadline_ValidFrom(t *testing.T) { + obl := V2Obligation{ValidFrom: "2026-08-02"} + d, ok := resolveDeadline(obl) + if !ok { + t.Fatal("expected deadline resolved from valid_from") + } + if d.Format("2006-01-02") != "2026-08-02" { + t.Errorf("got %s", d.Format("2006-01-02")) + } +} + +func TestResolveDeadline_NoDate(t *testing.T) { + obl := V2Obligation{} + _, ok := resolveDeadline(obl) + if ok { + t.Error("expected no deadline for empty obligation") + } +} + +func TestFormatLegalReference(t *testing.T) { + bases := []V2LegalBasis{ + {Norm: "DSGVO", Article: "Art. 22"}, + {Norm: "BGB", Article: "ยง 356a"}, + } + ref := formatLegalReference(bases) + if ref != "Art. 22 DSGVO, ยง 356a BGB" { + t.Errorf("got %q", ref) + } +} + +func TestGetRegulatoryNews_FromRealFiles(t *testing.T) { + regs, err := LoadAllV2Regulations() + if err != nil { + t.Skipf("could not load v2 regulations: %v", err) + } + items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 20, HorizonDays: 730}) + // Should find at least the Widerrufsbutton obligation + found := false + for _, item := range items { + if item.ID == "VBR-OBL-001" { + found = true + if item.Headline != "Widerrufsbutton-Pflicht ab 19. Juni 2026" { + t.Errorf("unexpected headline: %q", item.Headline) + } + } + } + if !found { + t.Error("expected VBR-OBL-001 in regulatory news") + } +} diff --git a/ai-compliance-sdk/internal/ucca/v2_loader.go b/ai-compliance-sdk/internal/ucca/v2_loader.go index e31682b..69557c1 100644 --- a/ai-compliance-sdk/internal/ucca/v2_loader.go +++ b/ai-compliance-sdk/internal/ucca/v2_loader.go @@ -58,6 +58,17 @@ type V2Obligation struct { Version string `json:"version,omitempty"` ISO27001Mapping []string `json:"iso27001_mapping,omitempty"` HowToImplement string `json:"how_to_implement,omitempty"` + News *V2ObligationNews `json:"news,omitempty"` +} + +// V2ObligationNews is news metadata for dashboard display. +// Own text referencing legal basis โ€” never copied from external sources. +type V2ObligationNews struct { + Headline string `json:"headline"` + Summary string `json:"summary"` + ActionRequired string `json:"action_required"` + Affected string `json:"affected,omitempty"` + ActionLink string `json:"action_link,omitempty"` } // V2LegalBasis is a legal reference in v2 format diff --git a/ai-compliance-sdk/policies/obligations/v2/verbraucherrecht_v2.json b/ai-compliance-sdk/policies/obligations/v2/verbraucherrecht_v2.json index afdcba4..a57bdd0 100644 --- a/ai-compliance-sdk/policies/obligations/v2/verbraucherrecht_v2.json +++ b/ai-compliance-sdk/policies/obligations/v2/verbraucherrecht_v2.json @@ -90,6 +90,13 @@ "priority": "critical", "tom_control_ids": [], "breakpilot_feature": "/sdk/consent-management", + "news": { + "headline": "Widerrufsbutton-Pflicht ab 19. Juni 2026", + "summary": "Ab dem 19. Juni 2026 muessen Unternehmen mit B2C-Online-Geschaeft einen digitalen Widerrufsbutton auf ihrer Website bereitstellen. Der Button muss von jeder Seite erreichbar sein und ein Zwei-Klick-Verfahren implementieren (EU-RL 2023/2673, ยง 356a BGB).", + "action_required": "Implementieren Sie einen gut sichtbaren 'Vertrag widerrufen'-Button auf Ihrer Website. Dokumentieren Sie das Zwei-Klick-Verfahren und stellen Sie die automatische Bestaetigungs-E-Mail sicher.", + "affected": "B2C, Online-Shop, Abo-Modelle", + "action_link": "/sdk/consent-management" + }, "valid_from": "2026-06-19", "valid_until": null, "version": "1.0"