Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 26s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 1m56s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 18s
- rag-constants.ts: 11 → 59 EDPB/WP29/EDPS + 20 DSFA Muss-Listen - ChunkBrowserQA: Dropdown von 3 auf 7 Collections erweitert (+ bp_dsfa_corpus, bp_compliance_recht, bp_legal_templates, bp_nibis_eh) - page.tsx: Collection-Totals aktualisiert (datenschutz 17459, dsfa 8666) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
676 lines
28 KiB
TypeScript
676 lines
28 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
|
|
import { REGULATIONS_IN_RAG, REGULATION_INFO } from '../rag-constants'
|
|
|
|
interface ChunkBrowserQAProps {
|
|
apiProxy: string
|
|
}
|
|
|
|
type RegGroupKey = 'eu_regulation' | 'eu_directive' | 'de_law' | 'at_law' | 'ch_law' | 'national_law' | 'bsi_standard' | 'eu_guideline' | 'international_standard' | 'other'
|
|
|
|
const GROUP_LABELS: Record<RegGroupKey, string> = {
|
|
eu_regulation: 'EU Verordnungen',
|
|
eu_directive: 'EU Richtlinien',
|
|
de_law: 'DE Gesetze',
|
|
at_law: 'AT Gesetze',
|
|
ch_law: 'CH Gesetze',
|
|
national_law: 'Nationale Gesetze (EU)',
|
|
bsi_standard: 'BSI Standards',
|
|
eu_guideline: 'EDPB / Guidelines',
|
|
international_standard: 'Internationale Standards',
|
|
other: 'Sonstige',
|
|
}
|
|
|
|
const GROUP_ORDER: RegGroupKey[] = [
|
|
'eu_regulation', 'eu_directive', 'de_law', 'at_law', 'ch_law',
|
|
'national_law', 'bsi_standard', 'eu_guideline', 'international_standard', 'other',
|
|
]
|
|
|
|
const COLLECTIONS = [
|
|
'bp_compliance_gesetze',
|
|
'bp_compliance_ce',
|
|
'bp_compliance_datenschutz',
|
|
'bp_dsfa_corpus',
|
|
'bp_compliance_recht',
|
|
'bp_legal_templates',
|
|
'bp_nibis_eh',
|
|
]
|
|
|
|
export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
|
|
// Filter-Sidebar
|
|
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
|
|
const [regulationCounts, setRegulationCounts] = useState<Record<string, number>>({})
|
|
const [filterSearch, setFilterSearch] = useState('')
|
|
const [countsLoading, setCountsLoading] = useState(false)
|
|
|
|
// Dokument-Chunks (sequenziell)
|
|
const [docChunks, setDocChunks] = useState<Record<string, unknown>[]>([])
|
|
const [docChunkIndex, setDocChunkIndex] = useState(0)
|
|
const [docTotalChunks, setDocTotalChunks] = useState(0)
|
|
const [docLoading, setDocLoading] = useState(false)
|
|
const docChunksRef = useRef(docChunks)
|
|
docChunksRef.current = docChunks
|
|
|
|
// Split-View
|
|
const [splitViewActive, setSplitViewActive] = useState(true)
|
|
const [chunksPerPage, setChunksPerPage] = useState(6)
|
|
const [fullscreen, setFullscreen] = useState(false)
|
|
|
|
// Collection — default to bp_compliance_ce where we have PDFs downloaded
|
|
const [collection, setCollection] = useState('bp_compliance_ce')
|
|
|
|
// PDF existence check
|
|
const [pdfExists, setPdfExists] = useState<boolean | null>(null)
|
|
|
|
// Sidebar collapsed groups
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
|
|
|
// Build grouped regulations for sidebar
|
|
const regulationsInCollection = Object.entries(REGULATIONS_IN_RAG)
|
|
.filter(([, info]) => info.collection === collection)
|
|
.map(([code]) => code)
|
|
|
|
const groupedRegulations = React.useMemo(() => {
|
|
const groups: Record<RegGroupKey, { code: string; name: string; type: string }[]> = {
|
|
eu_regulation: [], eu_directive: [], de_law: [], at_law: [], ch_law: [],
|
|
national_law: [], bsi_standard: [], eu_guideline: [], international_standard: [], other: [],
|
|
}
|
|
for (const code of regulationsInCollection) {
|
|
const reg = REGULATION_INFO.find(r => r.code === code)
|
|
const type = (reg?.type || 'other') as RegGroupKey
|
|
const groupKey = type in groups ? type : 'other'
|
|
groups[groupKey].push({
|
|
code,
|
|
name: reg?.name || code,
|
|
type: reg?.type || 'unknown',
|
|
})
|
|
}
|
|
return groups
|
|
}, [regulationsInCollection.join(',')])
|
|
|
|
// Load regulation counts for current collection
|
|
const loadRegulationCounts = useCallback(async (col: string) => {
|
|
const entries = Object.entries(REGULATIONS_IN_RAG)
|
|
.filter(([, info]) => info.collection === col && info.qdrant_id)
|
|
if (entries.length === 0) return
|
|
|
|
// Build qdrant_id -> our_code mapping
|
|
const qdrantIdToCode: Record<string, string[]> = {}
|
|
for (const [code, info] of entries) {
|
|
if (!qdrantIdToCode[info.qdrant_id]) qdrantIdToCode[info.qdrant_id] = []
|
|
qdrantIdToCode[info.qdrant_id].push(code)
|
|
}
|
|
const uniqueQdrantIds = Object.keys(qdrantIdToCode)
|
|
|
|
setCountsLoading(true)
|
|
try {
|
|
const params = new URLSearchParams({
|
|
action: 'regulation-counts-batch',
|
|
collection: col,
|
|
qdrant_ids: uniqueQdrantIds.join(','),
|
|
})
|
|
const res = await fetch(`${apiProxy}?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
// Map qdrant_id counts back to our codes
|
|
const mapped: Record<string, number> = {}
|
|
for (const [qid, count] of Object.entries(data.counts as Record<string, number>)) {
|
|
const codes = qdrantIdToCode[qid] || []
|
|
for (const code of codes) {
|
|
mapped[code] = count
|
|
}
|
|
}
|
|
setRegulationCounts(prev => ({ ...prev, ...mapped }))
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load regulation counts:', error)
|
|
} finally {
|
|
setCountsLoading(false)
|
|
}
|
|
}, [apiProxy])
|
|
|
|
// Load all chunks for a regulation (paginated scroll)
|
|
const loadDocumentChunks = useCallback(async (regulationCode: string) => {
|
|
const ragInfo = REGULATIONS_IN_RAG[regulationCode]
|
|
if (!ragInfo || !ragInfo.qdrant_id) return
|
|
|
|
setDocLoading(true)
|
|
setDocChunks([])
|
|
setDocChunkIndex(0)
|
|
setDocTotalChunks(0)
|
|
|
|
const allChunks: Record<string, unknown>[] = []
|
|
let offset: string | null = null
|
|
|
|
try {
|
|
let safety = 0
|
|
do {
|
|
const params = new URLSearchParams({
|
|
action: 'scroll',
|
|
collection: ragInfo.collection,
|
|
limit: '100',
|
|
filter_key: 'regulation_id',
|
|
filter_value: ragInfo.qdrant_id,
|
|
})
|
|
if (offset) params.append('offset', offset)
|
|
|
|
const res = await fetch(`${apiProxy}?${params}`)
|
|
if (!res.ok) break
|
|
|
|
const data = await res.json()
|
|
const chunks = data.chunks || []
|
|
allChunks.push(...chunks)
|
|
offset = data.next_offset || null
|
|
safety++
|
|
} while (offset && safety < 200)
|
|
|
|
// Sort by chunk_index
|
|
allChunks.sort((a, b) => {
|
|
const ai = Number(a.chunk_index ?? a.chunk_id ?? 0)
|
|
const bi = Number(b.chunk_index ?? b.chunk_id ?? 0)
|
|
return ai - bi
|
|
})
|
|
|
|
setDocChunks(allChunks)
|
|
setDocTotalChunks(allChunks.length)
|
|
setDocChunkIndex(0)
|
|
} catch (error) {
|
|
console.error('Failed to load document chunks:', error)
|
|
} finally {
|
|
setDocLoading(false)
|
|
}
|
|
}, [apiProxy])
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadRegulationCounts(collection)
|
|
}, [collection, loadRegulationCounts])
|
|
|
|
// Current chunk
|
|
const currentChunk = docChunks[docChunkIndex] || null
|
|
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
|
|
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
|
|
|
|
// PDF page estimation — use pages metadata if available
|
|
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
|
|
if (chunk) {
|
|
// Try pages array from payload (e.g. [7] or [7,8])
|
|
const pages = chunk.pages as number[] | undefined
|
|
if (Array.isArray(pages) && pages.length > 0) return pages[0]
|
|
// Try page field
|
|
const page = chunk.page as number | undefined
|
|
if (typeof page === 'number' && page > 0) return page
|
|
}
|
|
const mapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
|
const cpp = mapping?.chunksPerPage || chunksPerPage
|
|
return Math.floor(chunkIdx / cpp) + 1
|
|
}
|
|
|
|
const pdfPage = estimatePdfPage(currentChunk, docChunkIndex)
|
|
const pdfMapping = selectedRegulation ? RAG_PDF_MAPPING[selectedRegulation] : null
|
|
const pdfUrl = pdfMapping ? `/rag-originals/${pdfMapping.filename}#page=${pdfPage}` : null
|
|
|
|
// Check PDF existence when regulation changes
|
|
useEffect(() => {
|
|
if (!selectedRegulation) { setPdfExists(null); return }
|
|
const mapping = RAG_PDF_MAPPING[selectedRegulation]
|
|
if (!mapping) { setPdfExists(false); return }
|
|
const url = `/rag-originals/${mapping.filename}`
|
|
fetch(url, { method: 'HEAD' })
|
|
.then(res => setPdfExists(res.ok))
|
|
.catch(() => setPdfExists(false))
|
|
}, [selectedRegulation])
|
|
|
|
// Handlers
|
|
const handleSelectRegulation = (code: string) => {
|
|
setSelectedRegulation(code)
|
|
loadDocumentChunks(code)
|
|
}
|
|
|
|
const handleCollectionChange = (col: string) => {
|
|
setCollection(col)
|
|
setSelectedRegulation(null)
|
|
setDocChunks([])
|
|
setDocChunkIndex(0)
|
|
setDocTotalChunks(0)
|
|
setRegulationCounts({})
|
|
}
|
|
|
|
const handlePrev = () => {
|
|
if (docChunkIndex > 0) setDocChunkIndex(i => i - 1)
|
|
}
|
|
|
|
const handleNext = () => {
|
|
if (docChunkIndex < docChunks.length - 1) setDocChunkIndex(i => i + 1)
|
|
}
|
|
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && fullscreen) {
|
|
e.preventDefault()
|
|
setFullscreen(false)
|
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setDocChunkIndex(i => Math.max(0, i - 1))
|
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setDocChunkIndex(i => Math.min(docChunksRef.current.length - 1, i + 1))
|
|
}
|
|
}, [fullscreen])
|
|
|
|
useEffect(() => {
|
|
if (fullscreen || (selectedRegulation && docChunks.length > 0)) {
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}
|
|
}, [selectedRegulation, docChunks.length, handleKeyDown, fullscreen])
|
|
|
|
const toggleGroup = (group: string) => {
|
|
setCollapsedGroups(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(group)) next.delete(group)
|
|
else next.add(group)
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Get text content from a chunk
|
|
const getChunkText = (chunk: Record<string, unknown> | null): string => {
|
|
if (!chunk) return ''
|
|
return String(chunk.chunk_text || chunk.text || chunk.content || '')
|
|
}
|
|
|
|
// Extract structural metadata for prominent display
|
|
const getStructuralInfo = (chunk: Record<string, unknown> | null): { article?: string; section?: string; pages?: string } => {
|
|
if (!chunk) return {}
|
|
const result: { article?: string; section?: string; pages?: string } = {}
|
|
// Article / paragraph
|
|
const article = chunk.article || chunk.artikel || chunk.paragraph || chunk.section_title
|
|
if (article) result.article = String(article)
|
|
// Section
|
|
const section = chunk.section || chunk.chapter || chunk.abschnitt || chunk.kapitel
|
|
if (section) result.section = String(section)
|
|
// Pages
|
|
const pages = chunk.pages as number[] | undefined
|
|
if (Array.isArray(pages) && pages.length > 0) {
|
|
result.pages = pages.length === 1 ? `S. ${pages[0]}` : `S. ${pages[0]}-${pages[pages.length - 1]}`
|
|
} else if (chunk.page) {
|
|
result.pages = `S. ${chunk.page}`
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Overlap extraction
|
|
const getOverlapPrev = (): string => {
|
|
if (!prevChunk) return ''
|
|
const text = getChunkText(prevChunk)
|
|
return text.length > 150 ? '...' + text.slice(-150) : text
|
|
}
|
|
|
|
const getOverlapNext = (): string => {
|
|
if (!nextChunk) return ''
|
|
const text = getChunkText(nextChunk)
|
|
return text.length > 150 ? text.slice(0, 150) + '...' : text
|
|
}
|
|
|
|
// Filter sidebar items
|
|
const filteredRegulations = React.useMemo(() => {
|
|
if (!filterSearch.trim()) return groupedRegulations
|
|
const term = filterSearch.toLowerCase()
|
|
const filtered: typeof groupedRegulations = {
|
|
eu_regulation: [], eu_directive: [], de_law: [], at_law: [], ch_law: [],
|
|
national_law: [], bsi_standard: [], eu_guideline: [], international_standard: [], other: [],
|
|
}
|
|
for (const [group, items] of Object.entries(groupedRegulations)) {
|
|
filtered[group as RegGroupKey] = items.filter(
|
|
r => r.code.toLowerCase().includes(term) || r.name.toLowerCase().includes(term)
|
|
)
|
|
}
|
|
return filtered
|
|
}, [groupedRegulations, filterSearch])
|
|
|
|
// Regulation name lookup
|
|
const getRegName = (code: string): string => {
|
|
const reg = REGULATION_INFO.find(r => r.code === code)
|
|
return reg?.name || code
|
|
}
|
|
|
|
// Important metadata keys to show prominently
|
|
const STRUCTURAL_KEYS = new Set([
|
|
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
|
|
'abschnitt', 'kapitel', 'pages', 'page',
|
|
])
|
|
const HIDDEN_KEYS = new Set([
|
|
'text', 'content', 'chunk_text', 'id', 'embedding',
|
|
])
|
|
|
|
const structInfo = getStructuralInfo(currentChunk)
|
|
|
|
return (
|
|
<div
|
|
className={`flex flex-col ${fullscreen ? 'fixed inset-0 z-50 bg-slate-100 p-4' : ''}`}
|
|
style={fullscreen ? { height: '100vh' } : { height: 'calc(100vh - 220px)' }}
|
|
>
|
|
{/* Header bar — fixed height */}
|
|
<div className="flex-shrink-0 bg-white rounded-xl border border-slate-200 p-3 mb-3">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-500 mb-1">Collection</label>
|
|
<select
|
|
value={collection}
|
|
onChange={(e) => handleCollectionChange(e.target.value)}
|
|
className="px-3 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
|
>
|
|
{COLLECTIONS.map(c => (
|
|
<option key={c} value={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{selectedRegulation && (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-slate-900">
|
|
{selectedRegulation} — {getRegName(selectedRegulation)}
|
|
</span>
|
|
{structInfo.article && (
|
|
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 text-xs font-medium rounded">
|
|
{structInfo.article}
|
|
</span>
|
|
)}
|
|
{structInfo.pages && (
|
|
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
|
|
{structInfo.pages}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<button
|
|
onClick={handlePrev}
|
|
disabled={docChunkIndex === 0}
|
|
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
◀ Zurueck
|
|
</button>
|
|
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
|
|
{docChunkIndex + 1} / {docTotalChunks}
|
|
</span>
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={docChunkIndex >= docChunks.length - 1}
|
|
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
Weiter ▶
|
|
</button>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={docTotalChunks}
|
|
value={docChunkIndex + 1}
|
|
onChange={(e) => {
|
|
const v = parseInt(e.target.value, 10)
|
|
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) setDocChunkIndex(v - 1)
|
|
}}
|
|
className="w-16 px-2 py-1 border rounded text-xs text-center"
|
|
title="Springe zu Chunk Nr."
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-slate-500">Chunks/Seite:</label>
|
|
<select
|
|
value={chunksPerPage}
|
|
onChange={(e) => setChunksPerPage(Number(e.target.value))}
|
|
className="px-2 py-1 border rounded text-xs"
|
|
>
|
|
{[3, 4, 5, 6, 8, 10, 12, 15, 20].map(n => (
|
|
<option key={n} value={n}>{n}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => setSplitViewActive(!splitViewActive)}
|
|
className={`px-3 py-1 text-xs rounded-lg border ${
|
|
splitViewActive ? 'bg-teal-50 border-teal-300 text-teal-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
|
}`}
|
|
>
|
|
{splitViewActive ? 'Split-View an' : 'Split-View aus'}
|
|
</button>
|
|
<button
|
|
onClick={() => setFullscreen(!fullscreen)}
|
|
className={`px-3 py-1 text-xs rounded-lg border ${
|
|
fullscreen ? 'bg-indigo-50 border-indigo-300 text-indigo-700' : 'bg-slate-50 border-slate-300 text-slate-600'
|
|
}`}
|
|
title={fullscreen ? 'Vollbild beenden (Esc)' : 'Vollbild'}
|
|
>
|
|
{fullscreen ? '✕ Vollbild beenden' : '⛶ Vollbild'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content: Sidebar + Content — fills remaining height */}
|
|
<div className="flex gap-3 flex-1 min-h-0">
|
|
{/* Sidebar — scrollable */}
|
|
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
|
|
<div className="flex-shrink-0 p-3 border-b border-slate-100">
|
|
<input
|
|
type="text"
|
|
value={filterSearch}
|
|
onChange={(e) => setFilterSearch(e.target.value)}
|
|
placeholder="Suche..."
|
|
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
|
/>
|
|
{countsLoading && (
|
|
<div className="text-xs text-slate-400 mt-1 animate-pulse">Counts laden...</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
{GROUP_ORDER.map(group => {
|
|
const items = filteredRegulations[group]
|
|
if (items.length === 0) return null
|
|
const isCollapsed = collapsedGroups.has(group)
|
|
return (
|
|
<div key={group}>
|
|
<button
|
|
onClick={() => toggleGroup(group)}
|
|
className="w-full px-3 py-1.5 text-left text-xs font-semibold text-slate-500 bg-slate-50 hover:bg-slate-100 flex items-center justify-between sticky top-0 z-10"
|
|
>
|
|
<span>{GROUP_LABELS[group]}</span>
|
|
<span className="text-slate-400">{isCollapsed ? '+' : '-'}</span>
|
|
</button>
|
|
{!isCollapsed && items.map(reg => {
|
|
const count = regulationCounts[reg.code] ?? 0
|
|
const isSelected = selectedRegulation === reg.code
|
|
return (
|
|
<button
|
|
key={reg.code}
|
|
onClick={() => handleSelectRegulation(reg.code)}
|
|
className={`w-full px-3 py-1.5 text-left text-sm flex items-center justify-between hover:bg-teal-50 transition-colors ${
|
|
isSelected ? 'bg-teal-100 text-teal-900 font-medium' : 'text-slate-700'
|
|
}`}
|
|
>
|
|
<span className="truncate text-xs">{reg.name || reg.code}</span>
|
|
<span className={`text-xs tabular-nums flex-shrink-0 ml-1 ${count > 0 ? 'text-slate-500' : 'text-slate-300'}`}>
|
|
{count > 0 ? count.toLocaleString() : '—'}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content area — fills remaining width and height */}
|
|
{!selectedRegulation ? (
|
|
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
|
<div className="text-center text-slate-400 space-y-2">
|
|
<div className="text-4xl">🔍</div>
|
|
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
|
|
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
|
|
</div>
|
|
</div>
|
|
) : docLoading ? (
|
|
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
|
<div className="text-center text-slate-500 space-y-2">
|
|
<div className="animate-spin text-3xl">⚙</div>
|
|
<p className="text-sm">Chunks werden geladen...</p>
|
|
<p className="text-xs text-slate-400">
|
|
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
|
{/* Chunk-Text Panel — fixed height, internal scroll */}
|
|
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
|
{/* Panel header */}
|
|
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
|
|
<div className="flex items-center gap-2">
|
|
{structInfo.article && (
|
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
|
|
{structInfo.article}
|
|
</span>
|
|
)}
|
|
{structInfo.section && (
|
|
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
|
|
{structInfo.section}
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-slate-400 tabular-nums">
|
|
#{docChunkIndex} / {docTotalChunks - 1}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
|
|
{/* Overlap from previous chunk */}
|
|
{prevChunk && (
|
|
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
|
<div className="font-medium text-amber-600 mb-1">↑ Ende vorheriger Chunk #{docChunkIndex - 1}</div>
|
|
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current chunk text */}
|
|
{currentChunk ? (
|
|
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
|
|
{getChunkText(currentChunk)}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
|
|
)}
|
|
|
|
{/* Overlap from next chunk */}
|
|
{nextChunk && (
|
|
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
|
<div className="font-medium text-amber-600 mb-1">↓ Anfang naechster Chunk #{docChunkIndex + 1}</div>
|
|
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metadata */}
|
|
{currentChunk && (
|
|
<div className="mt-4 pt-3 border-t border-slate-100">
|
|
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
|
{Object.entries(currentChunk)
|
|
.filter(([k]) => !HIDDEN_KEYS.has(k))
|
|
.sort(([a], [b]) => {
|
|
// Structural keys first
|
|
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
|
|
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
|
|
return aStruct - bStruct || a.localeCompare(b)
|
|
})
|
|
.map(([k, v]) => (
|
|
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
|
|
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
|
|
<span className="text-slate-700 break-all">
|
|
{Array.isArray(v) ? v.join(', ') : String(v)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Chunk quality indicator */}
|
|
<div className="mt-3 pt-2 border-t border-slate-50">
|
|
<div className="text-xs text-slate-400">
|
|
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
|
|
{getChunkText(currentChunk).length < 50 && (
|
|
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr kurz</span>
|
|
)}
|
|
{getChunkText(currentChunk).length > 2000 && (
|
|
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr lang</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* PDF-Viewer Panel */}
|
|
{splitViewActive && (
|
|
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
|
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-400">
|
|
Seite ~{pdfPage}
|
|
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
|
|
</span>
|
|
{pdfUrl && (
|
|
<a
|
|
href={pdfUrl.split('#')[0]}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-teal-600 hover:text-teal-800 underline"
|
|
>
|
|
Oeffnen ↗
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-h-0 relative">
|
|
{pdfUrl && pdfExists ? (
|
|
<iframe
|
|
key={`${selectedRegulation}-${pdfPage}`}
|
|
src={pdfUrl}
|
|
className="absolute inset-0 w-full h-full border-0"
|
|
title="Original PDF"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
|
|
<div className="text-center space-y-2">
|
|
<div className="text-3xl">📄</div>
|
|
{!pdfMapping ? (
|
|
<>
|
|
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
|
|
<p className="text-xs">rag-pdf-mapping.ts ergaenzen.</p>
|
|
</>
|
|
) : pdfExists === false ? (
|
|
<>
|
|
<p className="font-medium text-orange-600">PDF nicht vorhanden</p>
|
|
<p className="text-xs">Datei <code className="bg-slate-100 px-1 rounded">{pdfMapping.filename}</code> fehlt in ~/rag-originals/</p>
|
|
<p className="text-xs mt-1">Bitte manuell herunterladen und dort ablegen.</p>
|
|
</>
|
|
) : (
|
|
<p>PDF wird geprueft...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|