Files
breakpilot-lehrer/admin-lehrer/app/(admin)/ai/quality/page.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

525 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* Quality & Audit Page
*
* Ermoeglicht Auditoren:
* - Chunk-Suche und Stichproben
* - Traceability: Chunk → Requirement → Control
* - Dokumenten-Vollstaendigkeitspruefung
*/
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { PagePurpose } from '@/components/common/PagePurpose'
const API_PROXY = '/api/legal-corpus'
// Types
interface ChunkDetail {
id: string
text: string
regulation_code: string
regulation_name: string
article: string | null
paragraph: string | null
chunk_index: number
chunk_position: 'beginning' | 'middle' | 'end'
source_url: string
score?: number
}
interface Requirement {
id: string
text: string
category: string
source_chunk_id: string
regulation_code: string
}
interface Control {
id: string
name: string
description: string
source_requirement_ids: string[]
regulation_codes: string[]
}
interface TraceabilityResult {
chunk: ChunkDetail
requirements: Requirement[]
controls: Control[]
}
// Regulations for filtering
const REGULATIONS = [
{ code: 'GDPR', name: 'DSGVO' },
{ code: 'EPRIVACY', name: 'ePrivacy' },
{ code: 'TDDDG', name: 'TDDDG' },
{ code: 'SCC', name: 'Standardvertragsklauseln' },
{ code: 'DPF', name: 'EU-US DPF' },
{ code: 'AIACT', name: 'EU AI Act' },
{ code: 'CRA', name: 'Cyber Resilience Act' },
{ code: 'NIS2', name: 'NIS2' },
{ code: 'EUCSA', name: 'EU Cybersecurity Act' },
{ code: 'DATAACT', name: 'Data Act' },
{ code: 'DGA', name: 'Data Governance Act' },
{ code: 'DSA', name: 'Digital Services Act' },
{ code: 'EAA', name: 'Accessibility Act' },
{ code: 'DSM', name: 'DSM-Urheberrecht' },
{ code: 'PLD', name: 'Produkthaftung' },
{ code: 'GPSR', name: 'Product Safety' },
{ code: 'BSI-TR-03161-1', name: 'BSI-TR Teil 1' },
{ code: 'BSI-TR-03161-2', name: 'BSI-TR Teil 2' },
{ code: 'BSI-TR-03161-3', name: 'BSI-TR Teil 3' },
]
const TYPE_COLORS: Record<string, string> = {
eu_regulation: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
eu_directive: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
de_law: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
bsi_standard: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
export default function QualityPage() {
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ChunkDetail[]>([])
const [searching, setSearching] = useState(false)
const [selectedRegulation, setSelectedRegulation] = useState<string>('')
const [topK, setTopK] = useState(10)
// Traceability state
const [selectedChunk, setSelectedChunk] = useState<ChunkDetail | null>(null)
const [traceability, setTraceability] = useState<TraceabilityResult | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
// Quick sample queries for auditors
const sampleQueries = [
{ label: 'Art. 17 DSGVO (Recht auf Loeschung)', query: 'Recht auf Löschung Artikel 17', reg: 'GDPR' },
{ label: 'Einwilligung TDDDG', query: 'Einwilligung Endeinrichtung speichern', reg: 'TDDDG' },
{ label: 'AI Act Hochrisiko', query: 'Hochrisiko-KI-System Anforderungen', reg: 'AIACT' },
{ label: 'NIS2 Sicherheitsmaßnahmen', query: 'Cybersicherheitsrisikomanagement Maßnahmen', reg: 'NIS2' },
{ label: 'BSI Authentifizierung', query: 'Authentifizierung Zwei-Faktor mobile', reg: 'BSI-TR-03161-1' },
]
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setSearching(true)
setSearchResults([])
setSelectedChunk(null)
setTraceability(null)
try {
let url = `${API_PROXY}?action=search&query=${encodeURIComponent(searchQuery)}&top_k=${topK}`
if (selectedRegulation) {
url += `&regulations=${encodeURIComponent(selectedRegulation)}`
}
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
setSearchResults(data.results || [])
}
} catch (error) {
console.error('Search failed:', error)
} finally {
setSearching(false)
}
}, [searchQuery, selectedRegulation, topK])
const loadTraceability = useCallback(async (chunk: ChunkDetail) => {
setSelectedChunk(chunk)
setLoadingTrace(true)
try {
// Try to load traceability (requirements and controls derived from this chunk)
const res = await fetch(`${API_PROXY}?action=traceability&chunk_id=${encodeURIComponent(chunk.id || chunk.regulation_code + '_' + chunk.chunk_index)}&regulation=${encodeURIComponent(chunk.regulation_code)}`)
if (res.ok) {
const data = await res.json()
setTraceability({
chunk,
requirements: data.requirements || [],
controls: data.controls || [],
})
} else {
// If traceability endpoint doesn't exist yet, show placeholder
setTraceability({
chunk,
requirements: [],
controls: [],
})
}
} catch (error) {
console.error('Failed to load traceability:', error)
setTraceability({
chunk,
requirements: [],
controls: [],
})
} finally {
setLoadingTrace(false)
}
}, [])
const handleSampleQuery = (query: string, reg: string) => {
setSearchQuery(query)
setSelectedRegulation(reg)
// Auto-search after setting
setTimeout(() => {
handleSearch()
}, 100)
}
const highlightText = (text: string, query: string) => {
if (!query) return text
const words = query.toLowerCase().split(' ').filter(w => w.length > 2)
let result = text
words.forEach(word => {
const regex = new RegExp(`(${word})`, 'gi')
result = result.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-0.5 rounded">$1</mark>')
})
return result
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Qualitaet & Audit
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Stichproben und Traceability fuer Compliance-Auditoren
</p>
</div>
<Link
href="/ai/rag"
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Zurueck zu RAG
</Link>
</div>
<PagePurpose
title="Audit-Werkzeuge"
purpose="Pruefen Sie die Qualitaet der Compliance-Datenbank. Suchen Sie gezielt nach Paragraphen, Saetzen oder Begriffen und verfolgen Sie, wie Anforderungen und Controls abgeleitet wurden."
audience={['Auditoren', 'Compliance-Beauftragte', 'Qualitaetssicherung']}
architecture={{
services: ['klausur-service', 'embedding-service', 'qdrant'],
databases: ['Qdrant Vector DB']
}}
/>
{/* Quick Sample Queries */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Schnell-Stichproben
</h3>
<div className="flex flex-wrap gap-2">
{sampleQueries.map((sq, idx) => (
<button
key={idx}
onClick={() => handleSampleQuery(sq.query, sq.reg)}
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 rounded-full transition-colors"
>
{sq.label}
</button>
))}
</div>
</div>
{/* Search Section */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Chunk-Suche
</h2>
<div className="space-y-4">
{/* Search Input */}
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchbegriff / Paragraph / Artikeltext
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. 'Recht auf Löschung' oder 'Art. 17 Abs. 1'"
className="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Regulierung
</label>
<select
value={selectedRegulation}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="">Alle</option>
{REGULATIONS.map((reg) => (
<option key={reg.code} value={reg.code}>
{reg.name}
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Anzahl
</label>
<select
value={topK}
onChange={(e) => setTopK(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-slate-700 dark:text-white"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
</select>
</div>
</div>
<button
onClick={handleSearch}
disabled={searching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{searching ? 'Suche laeuft...' : 'Suchen'}
</button>
</div>
</div>
{/* Results Grid */}
{searchResults.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Search Results List */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Gefundene Chunks ({searchResults.length})
</h3>
<div className="space-y-3 max-h-[600px] overflow-y-auto">
{searchResults.map((result, idx) => (
<div
key={idx}
onClick={() => loadTraceability(result)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedChunk?.text === result.text
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-slate-700 hover:border-gray-300 dark:hover:border-slate-600'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{result.regulation_code}
</span>
{result.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {result.article}
{result.paragraph && ` Abs. ${result.paragraph}`}
</span>
)}
</div>
<span className="text-xs text-gray-400">
Score: {(result.score || 0).toFixed(3)}
</span>
</div>
{/* Text Preview */}
<p
className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4"
dangerouslySetInnerHTML={{
__html: highlightText(result.text.substring(0, 400) + (result.text.length > 400 ? '...' : ''), searchQuery)
}}
/>
{/* Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>Chunk #{result.chunk_index || idx}</span>
<span>{result.text.length} Zeichen</span>
</div>
</div>
))}
</div>
</div>
{/* Traceability Panel */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-white mb-4">
Traceability
</h3>
{!selectedChunk ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<p>Waehlen Sie einen Chunk aus der Liste, um die Traceability zu sehen.</p>
</div>
) : loadingTrace ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Lade Traceability...</p>
</div>
) : traceability ? (
<div className="space-y-6">
{/* Selected Chunk Detail */}
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📄 Ausgewaehlter Chunk
</h4>
<div className="bg-gray-50 dark:bg-slate-700 rounded p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded">
{traceability.chunk.regulation_code}
</span>
{traceability.chunk.article && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Art. {traceability.chunk.article}
{traceability.chunk.paragraph && ` Abs. ${traceability.chunk.paragraph}`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
{traceability.chunk.text}
</p>
{traceability.chunk.source_url && (
<a
href={traceability.chunk.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-600 hover:underline"
>
🔗 Quelle oeffnen
</a>
)}
</div>
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
{/* Requirements */}
<div className="border-l-4 border-orange-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
📋 Extrahierte Anforderungen ({traceability.requirements.length})
</h4>
{traceability.requirements.length > 0 ? (
<div className="space-y-2">
{traceability.requirements.map((req, idx) => (
<div key={idx} className="bg-orange-50 dark:bg-orange-900/20 rounded p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
{req.category || 'Anforderung'}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{req.text}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Anforderungen aus diesem Chunk extrahiert.
<br />
<span className="text-xs">(Requirements-Extraktion ist noch nicht implementiert)</span>
</p>
)}
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
{/* Controls */}
<div className="border-l-4 border-green-500 pl-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Abgeleitete Controls ({traceability.controls.length})
</h4>
{traceability.controls.length > 0 ? (
<div className="space-y-2">
{traceability.controls.map((ctrl, idx) => (
<div key={idx} className="bg-green-50 dark:bg-green-900/20 rounded p-3">
<div className="font-medium text-sm text-green-700 dark:text-green-400 mb-1">
{ctrl.name}
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{ctrl.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
Keine Controls aus diesem Chunk abgeleitet.
<br />
<span className="text-xs">(Control-Ableitung ist noch nicht implementiert)</span>
</p>
)}
</div>
</div>
) : null}
</div>
</div>
)}
{/* Empty State */}
{!searching && searchResults.length === 0 && searchQuery && (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Keine Ergebnisse gefunden
</h3>
<p className="text-gray-500 dark:text-gray-400">
Versuchen Sie einen anderen Suchbegriff oder waehlen Sie eine andere Regulierung.
</p>
</div>
)}
{/* Initial State */}
{!searching && searchResults.length === 0 && !searchQuery && (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-12 text-center">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Bereit fuer Stichproben
</h3>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Geben Sie einen Suchbegriff ein, um Chunks zu finden. Sie koennen nach Artikeln,
Paragraphen oder spezifischen Textpassagen suchen.
</p>
</div>
)}
{/* Audit Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-400 mb-2">
Hinweise fuer Auditoren
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1 list-disc list-inside">
<li>Die Suche ist semantisch - aehnliche Begriffe werden gefunden, auch wenn die exakte Formulierung abweicht</li>
<li>Jeder Chunk entspricht einem logischen Textabschnitt aus dem Originaldokument</li>
<li>Die Traceability zeigt, wie aus dem Originaltext Anforderungen und Controls abgeleitet wurden</li>
<li>Klicken Sie auf "Quelle oeffnen", um das Originaldokument zu pruefen</li>
</ul>
</div>
</div>
)
}