feat: Regulatory News Dashboard — proaktive Compliance-Alerts
Some checks failed
Build + Deploy / build-backend-compliance (push) Successful in 2m43s
Build + Deploy / build-admin-compliance (push) Successful in 1m46s
Build + Deploy / build-ai-sdk (push) Successful in 47s
Build + Deploy / build-developer-portal (push) Successful in 1m0s
Build + Deploy / build-tts (push) Successful in 1m14s
Build + Deploy / build-document-crawler (push) Successful in 37s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
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 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 27s
CI / validate-canonical-controls (push) Successful in 23s
Build + Deploy / trigger-orca (push) Failing after 2h32m34s
Some checks failed
Build + Deploy / build-backend-compliance (push) Successful in 2m43s
Build + Deploy / build-admin-compliance (push) Successful in 1m46s
Build + Deploy / build-ai-sdk (push) Successful in 47s
Build + Deploy / build-developer-portal (push) Successful in 1m0s
Build + Deploy / build-tts (push) Successful in 1m14s
Build + Deploy / build-document-crawler (push) Successful in 37s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
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 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 27s
CI / validate-canonical-controls (push) Successful in 23s
Build + Deploy / trigger-orca (push) Failing after 2h32m34s
Zeigt anstehende regulatorische Fristen im Dashboard an, abgeleitet aus den bestehenden Obligation v2 JSON-Dateien. Keine neue DB-Tabelle. Erster News-Eintrag: Widerrufsbutton-Pflicht ab 19.06.2026 (EU-RL 2023/2673, §356a BGB) — eigener Text, keine externe Quelle. Features: - Go Service: scannt Obligations nach Fristen, berechnet Urgency - API: GET /sdk/v1/regulatory-news mit Countdown + Farbcodierung - Dashboard: RegulatoryNewsFeed Sektion mit Countdown-Badges - Vorlage: news-Feld in v2 JSON fuer zukuenftige regulatorische Updates - 11 Tests (Sortierung, Urgency, Deadline-Parsing, Real-File-Test) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
admin-compliance/app/api/sdk/v1/regulatory-news/route.ts
Normal file
26
admin-compliance/app/api/sdk/v1/regulatory-news/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import React from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||||
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
||||||
|
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
|
||||||
import type { SDKPackageId } from '@/lib/sdk/types'
|
import type { SDKPackageId } from '@/lib/sdk/types'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -331,6 +332,9 @@ export default function SDKDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Regulatory News */}
|
||||||
|
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
|
||||||
|
|
||||||
{/* 5 Packages */}
|
{/* 5 Packages */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={`bg-white border border-gray-200 border-l-4 ${style.border} rounded-lg p-4`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">{item.headline}</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{item.summary}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 italic">{item.legal_reference}</p>
|
||||||
|
{item.sanctions && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">Sanktionen: {item.sanctions}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${style.badge}`}>
|
||||||
|
{style.icon} {item.days_remaining} Tage
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(item.deadline).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-400">{item.affected}</span>
|
||||||
|
{item.action_link && (
|
||||||
|
<Link href={item.action_link} className="text-xs text-blue-600 hover:underline font-medium">
|
||||||
|
Massnahme ergreifen →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<RegulatoryNewsItemData[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<span>📢</span> Regulatorische Neuigkeiten
|
||||||
|
</h2>
|
||||||
|
{items.length > maxItems && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{showAll ? 'Weniger' : `Alle ${items.length} anzeigen`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visible.map(item => (
|
||||||
|
<RegulatoryNewsCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -134,6 +134,14 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||||
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
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
|
// Maximizer
|
||||||
maximizerStore := maximizer.NewStore(pool)
|
maximizerStore := maximizer.NewStore(pool)
|
||||||
maximizerRules, err := maximizer.LoadConstraintRulesFromDefault()
|
maximizerRules, err := maximizer.LoadConstraintRulesFromDefault()
|
||||||
@@ -168,7 +176,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||||
maximizerHandlers)
|
maximizerHandlers, regulatoryNewsHandlers)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func registerRoutes(
|
|||||||
whistleblowerHandlers *handlers.WhistleblowerHandlers,
|
whistleblowerHandlers *handlers.WhistleblowerHandlers,
|
||||||
iaceHandler *handlers.IACEHandler,
|
iaceHandler *handlers.IACEHandler,
|
||||||
maximizerHandlers *handlers.MaximizerHandlers,
|
maximizerHandlers *handlers.MaximizerHandlers,
|
||||||
|
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||||
) {
|
) {
|
||||||
v1 := router.Group("/sdk/v1")
|
v1 := router.Group("/sdk/v1")
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,7 @@ func registerRoutes(
|
|||||||
registerWhistleblowerRoutes(v1, whistleblowerHandlers)
|
registerWhistleblowerRoutes(v1, whistleblowerHandlers)
|
||||||
registerIACERoutes(v1, iaceHandler)
|
registerIACERoutes(v1, iaceHandler)
|
||||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||||
|
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
146
ai-compliance-sdk/internal/ucca/regulatory_news.go
Normal file
146
ai-compliance-sdk/internal/ucca/regulatory_news.go
Normal file
@@ -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, ", ")
|
||||||
|
}
|
||||||
191
ai-compliance-sdk/internal/ucca/regulatory_news_test.go
Normal file
191
ai-compliance-sdk/internal/ucca/regulatory_news_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,17 @@ type V2Obligation struct {
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
ISO27001Mapping []string `json:"iso27001_mapping,omitempty"`
|
ISO27001Mapping []string `json:"iso27001_mapping,omitempty"`
|
||||||
HowToImplement string `json:"how_to_implement,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
|
// V2LegalBasis is a legal reference in v2 format
|
||||||
|
|||||||
@@ -90,6 +90,13 @@
|
|||||||
"priority": "critical",
|
"priority": "critical",
|
||||||
"tom_control_ids": [],
|
"tom_control_ids": [],
|
||||||
"breakpilot_feature": "/sdk/consent-management",
|
"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_from": "2026-06-19",
|
||||||
"valid_until": null,
|
"valid_until": null,
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user