backend-lehrer (10 files): - game/database.py (785 → 5), correction_api.py (683 → 4) - classroom_engine/antizipation.py (676 → 5) - llm_gateway schools/edu_search already done in prior batch klausur-service (12 files): - orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4) - zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5) - eh_templates.py (658 → 5), mail/api.py (651 → 5) - qdrant_service.py (638 → 5), training_api.py (625 → 4) website (6 pages): - middleware (696 → 8), mail (733 → 6), consent (628 → 8) - compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7) studio-v2 (3 components): - B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2) - dashboard-experimental (739 → 2) admin-lehrer (4 files): - uebersetzungen (769 → 4), manager (670 → 2) - ChunkBrowserQA (675 → 6), dsfa/page (674 → 5) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
283 lines
9.9 KiB
TypeScript
283 lines
9.9 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
|
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
|
|
import { REGULATIONS_IN_RAG, REGULATION_INFO } from '../rag-constants'
|
|
import { RegGroupKey } from './ChunkBrowserConstants'
|
|
import { getStructuralInfo } from './ChunkBrowserHelpers'
|
|
import { ChunkBrowserSidebar } from './ChunkBrowserSidebar'
|
|
import { ChunkBrowserToolbar } from './ChunkBrowserToolbar'
|
|
import { ChunkBrowserContent } from './ChunkBrowserContent'
|
|
|
|
interface ChunkBrowserQAProps {
|
|
apiProxy: string
|
|
}
|
|
|
|
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
|
|
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 = 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
|
|
|
|
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()
|
|
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)
|
|
|
|
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])
|
|
|
|
// 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])
|
|
|
|
// Keyboard navigation
|
|
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])
|
|
|
|
// Handlers
|
|
const handleSelectRegulation = (code: string) => {
|
|
setSelectedRegulation(code)
|
|
loadDocumentChunks(code)
|
|
}
|
|
|
|
const handleCollectionChange = (col: string) => {
|
|
setCollection(col)
|
|
setSelectedRegulation(null)
|
|
setDocChunks([])
|
|
setDocChunkIndex(0)
|
|
setDocTotalChunks(0)
|
|
setRegulationCounts({})
|
|
}
|
|
|
|
const toggleGroup = (group: string) => {
|
|
setCollapsedGroups(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(group)) next.delete(group)
|
|
else next.add(group)
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Filter sidebar items
|
|
const filteredRegulations = 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])
|
|
|
|
const currentChunk = docChunks[docChunkIndex] || null
|
|
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)' }}
|
|
>
|
|
<ChunkBrowserToolbar
|
|
collection={collection}
|
|
onCollectionChange={handleCollectionChange}
|
|
selectedRegulation={selectedRegulation}
|
|
structInfo={structInfo}
|
|
docChunkIndex={docChunkIndex}
|
|
docTotalChunks={docTotalChunks}
|
|
docChunksLength={docChunks.length}
|
|
chunksPerPage={chunksPerPage}
|
|
setChunksPerPage={setChunksPerPage}
|
|
splitViewActive={splitViewActive}
|
|
setSplitViewActive={setSplitViewActive}
|
|
fullscreen={fullscreen}
|
|
setFullscreen={setFullscreen}
|
|
onPrev={() => { if (docChunkIndex > 0) setDocChunkIndex(i => i - 1) }}
|
|
onNext={() => { if (docChunkIndex < docChunks.length - 1) setDocChunkIndex(i => i + 1) }}
|
|
onJumpTo={setDocChunkIndex}
|
|
/>
|
|
|
|
<div className="flex gap-3 flex-1 min-h-0">
|
|
<ChunkBrowserSidebar
|
|
filterSearch={filterSearch}
|
|
setFilterSearch={setFilterSearch}
|
|
countsLoading={countsLoading}
|
|
filteredRegulations={filteredRegulations}
|
|
regulationCounts={regulationCounts}
|
|
selectedRegulation={selectedRegulation}
|
|
collapsedGroups={collapsedGroups}
|
|
onSelectRegulation={handleSelectRegulation}
|
|
onToggleGroup={toggleGroup}
|
|
/>
|
|
|
|
<ChunkBrowserContent
|
|
selectedRegulation={selectedRegulation}
|
|
docLoading={docLoading}
|
|
docChunks={docChunks}
|
|
docChunkIndex={docChunkIndex}
|
|
docTotalChunks={docTotalChunks}
|
|
splitViewActive={splitViewActive}
|
|
chunksPerPage={chunksPerPage}
|
|
pdfExists={pdfExists}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|