[split-required] Split 500-850 LOC files (batch 2)

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>
This commit is contained in:
Benjamin Admin
2026-04-25 08:24:01 +02:00
parent 34da9f4cda
commit b4613e26f3
118 changed files with 15258 additions and 14680 deletions
@@ -0,0 +1,61 @@
'use client'
import React from 'react'
import { Scale, CheckCircle, Clock, AlertCircle } from 'lucide-react'
import {
DSFALicenseCode,
DSFA_LICENSE_LABELS,
DSFA_DOCUMENT_TYPE_LABELS,
} from '@/lib/sdk/types'
export function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const colorMap: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
<Scale className="w-3 h-3" />
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
</span>
)
}
export function DocumentTypeBadge({ type }: { type?: string }) {
if (!type) return null
const colorMap: Record<string, string> = {
guideline: 'bg-indigo-100 text-indigo-700',
checklist: 'bg-emerald-100 text-emerald-700',
regulation: 'bg-red-100 text-red-700',
template: 'bg-orange-100 text-orange-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
</span>
)
}
export function StatusIndicator({ status }: { status: string }) {
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
}
const config = statusConfig[status] || statusConfig.yellow
return (
<span className={`inline-flex items-center gap-1 ${config.color}`}>
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
)
}
@@ -0,0 +1,137 @@
'use client'
import { useState } from 'react'
import {
RefreshCw,
ChevronDown,
ChevronUp,
ExternalLink,
} from 'lucide-react'
import { DSFASource, DSFASourceStats } from '@/lib/sdk/types'
import { LicenseBadge, DocumentTypeBadge } from './DSFABadges'
interface SourceCardProps {
source: DSFASource
stats?: DSFASourceStats
onIngest: () => void
isIngesting: boolean
}
export function SourceCard({ source, stats, onIngest, isIngesting }: SourceCardProps) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{source.sourceCode}
</span>
<DocumentTypeBadge type={source.documentType} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{source.name}
</h3>
{source.organization && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{source.organization}
</p>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
<div className="flex items-center gap-4 mt-3">
<LicenseBadge licenseCode={source.licenseCode} />
{stats && (
<>
<span className="text-sm text-gray-500">
{stats.documentCount} Dok.
</span>
<span className="text-sm text-gray-500">
{stats.chunkCount} Chunks
</span>
</>
)}
</div>
{source.attributionRequired && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
<strong>Attribution:</strong> {source.attributionText}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<dl className="grid grid-cols-2 gap-3 text-sm">
{source.sourceUrl && (
<>
<dt className="text-gray-500">Quelle:</dt>
<dd>
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
{source.licenseUrl && (
<>
<dt className="text-gray-500">Lizenz-URL:</dt>
<dd>
<a
href={source.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{source.licenseName} <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
<dt className="text-gray-500">Sprache:</dt>
<dd className="uppercase">{source.language}</dd>
{stats?.lastIndexedAt && (
<>
<dt className="text-gray-500">Zuletzt indexiert:</dt>
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
</>
)}
</dl>
<div className="mt-4 flex gap-2">
<button
onClick={onIngest}
disabled={isIngesting}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isIngesting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Neu indexieren
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,59 @@
'use client'
import { Database } from 'lucide-react'
import { DSFACorpusStats } from '@/lib/sdk/types'
import { StatusIndicator } from './DSFABadges'
interface StatsOverviewProps {
stats: DSFACorpusStats
}
export function StatsOverview({ stats }: StatsOverviewProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-5 h-5" />
Corpus-Statistik
</h2>
<StatusIndicator status={stats.qdrantStatus} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stats.totalSources}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.totalDocuments}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stats.totalChunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stats.qdrantPointsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Collection:</strong>{' '}
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
{stats.qdrantCollection}
</code>
</p>
</div>
</div>
)
}
@@ -0,0 +1,137 @@
/**
* DSFA API functions and mock data.
*/
import {
DSFASource,
DSFACorpusStats,
} from '@/lib/sdk/types'
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export const MOCK_SOURCES: DSFASource[] = [
{
id: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01 - Leitlinien zur DSFA',
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
licenseCode: 'EDPB-LICENSE',
licenseName: 'EDPB Document License',
attributionRequired: true,
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
documentType: 'guideline',
language: 'de',
},
{
id: '2',
sourceCode: 'DSK_KP5',
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
organization: 'Datenschutzkonferenz (DSK)',
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
attributionRequired: true,
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
documentType: 'guideline',
language: 'de',
},
{
id: '3',
sourceCode: 'BFDI_MUSS_PUBLIC',
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
organization: 'BfDI',
sourceUrl: 'https://www.bfdi.bund.de',
licenseCode: 'DL-DE-ZERO-2.0',
licenseName: 'Datenlizenz DE \u2013 Zero 2.0',
attributionRequired: false,
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
documentType: 'checklist',
language: 'de',
},
{
id: '4',
sourceCode: 'NI_MUSS_PRIVATE',
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
organization: 'LfD Niedersachsen',
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE \u2013 Namensnennung 2.0',
attributionRequired: true,
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
documentType: 'checklist',
language: 'de',
},
]
export const MOCK_STATS: DSFACorpusStats = {
sources: [
{
sourceId: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01',
organization: 'EDPB',
licenseCode: 'EDPB-LICENSE',
documentType: 'guideline',
documentCount: 1,
chunkCount: 50,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
{
sourceId: '2',
sourceCode: 'DSK_KP5',
name: 'DSK Kurzpapier Nr. 5',
organization: 'DSK',
licenseCode: 'DL-DE-BY-2.0',
documentType: 'guideline',
documentCount: 1,
chunkCount: 35,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
],
totalSources: 45,
totalDocuments: 45,
totalChunks: 850,
qdrantCollection: 'bp_dsfa_corpus',
qdrantPointsCount: 850,
qdrantStatus: 'green',
}
export async function fetchSources(): Promise<DSFASource[]> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
if (!response.ok) throw new Error('Failed to fetch sources')
return await response.json()
} catch {
return MOCK_SOURCES
}
}
export async function fetchStats(): Promise<DSFACorpusStats> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch {
return MOCK_STATS
}
}
export async function initializeCorpus(): Promise<{ sources_registered: number }> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to initialize corpus')
return await response.json()
}
export async function triggerIngestion(sourceCode: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) throw new Error('Failed to trigger ingestion')
}
@@ -4,11 +4,6 @@
* DSFA Document Manager
*
* Manages DSFA-related sources and documents for the RAG pipeline.
* Features:
* - View all registered DSFA sources with license info
* - Upload new documents
* - Trigger re-indexing
* - View corpus statistics
*/
import { useState, useEffect } from 'react'
@@ -19,411 +14,24 @@ import {
Upload,
FileText,
Database,
Scale,
ExternalLink,
ChevronDown,
ChevronUp,
Search,
Filter,
CheckCircle,
Clock,
AlertCircle,
BookOpen
BookOpen,
} from 'lucide-react'
import { DSFASource, DSFACorpusStats, DSFASourceStats } from '@/lib/sdk/types'
import {
DSFASource,
DSFACorpusStats,
DSFASourceStats,
DSFALicenseCode,
DSFA_LICENSE_LABELS,
DSFA_DOCUMENT_TYPE_LABELS
} from '@/lib/sdk/types'
// ============================================================================
// TYPES
// ============================================================================
interface APIError {
message: string
status?: number
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
async function fetchSources(): Promise<DSFASource[]> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources`)
if (!response.ok) throw new Error('Failed to fetch sources')
return await response.json()
} catch {
// Return mock data for demo
return MOCK_SOURCES
}
}
async function fetchStats(): Promise<DSFACorpusStats> {
try {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/stats`)
if (!response.ok) throw new Error('Failed to fetch stats')
return await response.json()
} catch {
return MOCK_STATS
}
}
async function initializeCorpus(): Promise<{ sources_registered: number }> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/init`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to initialize corpus')
return await response.json()
}
async function triggerIngestion(sourceCode: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/dsfa-rag/sources/${sourceCode}/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!response.ok) throw new Error('Failed to trigger ingestion')
}
// ============================================================================
// MOCK DATA
// ============================================================================
const MOCK_SOURCES: DSFASource[] = [
{
id: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01 - Leitlinien zur DSFA',
fullName: 'Leitlinien zur Datenschutz-Folgenabschaetzung',
organization: 'Artikel-29-Datenschutzgruppe / EDPB',
sourceUrl: 'https://ec.europa.eu/newsroom/article29/items/611236/en',
licenseCode: 'EDPB-LICENSE',
licenseName: 'EDPB Document License',
attributionRequired: true,
attributionText: 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)',
documentType: 'guideline',
language: 'de',
},
{
id: '2',
sourceCode: 'DSK_KP5',
name: 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO',
organization: 'Datenschutzkonferenz (DSK)',
sourceUrl: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
attributionRequired: true,
attributionText: 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)',
documentType: 'guideline',
language: 'de',
},
{
id: '3',
sourceCode: 'BFDI_MUSS_PUBLIC',
name: 'BfDI DSFA-Liste (oeffentlicher Bereich)',
organization: 'BfDI',
sourceUrl: 'https://www.bfdi.bund.de',
licenseCode: 'DL-DE-ZERO-2.0',
licenseName: 'Datenlizenz DE Zero 2.0',
attributionRequired: false,
attributionText: 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO',
documentType: 'checklist',
language: 'de',
},
{
id: '4',
sourceCode: 'NI_MUSS_PRIVATE',
name: 'LfD NI DSFA-Liste (nicht-oeffentlich)',
organization: 'LfD Niedersachsen',
sourceUrl: 'https://www.lfd.niedersachsen.de/download/131098',
licenseCode: 'DL-DE-BY-2.0',
licenseName: 'Datenlizenz DE Namensnennung 2.0',
attributionRequired: true,
attributionText: 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste',
documentType: 'checklist',
language: 'de',
},
]
const MOCK_STATS: DSFACorpusStats = {
sources: [
{
sourceId: '1',
sourceCode: 'WP248',
name: 'WP248 rev.01',
organization: 'EDPB',
licenseCode: 'EDPB-LICENSE',
documentType: 'guideline',
documentCount: 1,
chunkCount: 50,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
{
sourceId: '2',
sourceCode: 'DSK_KP5',
name: 'DSK Kurzpapier Nr. 5',
organization: 'DSK',
licenseCode: 'DL-DE-BY-2.0',
documentType: 'guideline',
documentCount: 1,
chunkCount: 35,
lastIndexedAt: '2026-02-09T10:00:00Z',
},
],
totalSources: 45,
totalDocuments: 45,
totalChunks: 850,
qdrantCollection: 'bp_dsfa_corpus',
qdrantPointsCount: 850,
qdrantStatus: 'green',
}
// ============================================================================
// COMPONENTS
// ============================================================================
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const colorMap: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'bg-blue-100 text-blue-700 border-blue-200',
'DL-DE-ZERO-2.0': 'bg-gray-100 text-gray-700 border-gray-200',
'CC-BY-4.0': 'bg-green-100 text-green-700 border-green-200',
'EDPB-LICENSE': 'bg-purple-100 text-purple-700 border-purple-200',
'PUBLIC_DOMAIN': 'bg-gray-100 text-gray-600 border-gray-200',
'PROPRIETARY': 'bg-amber-100 text-amber-700 border-amber-200',
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorMap[licenseCode] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
<Scale className="w-3 h-3" />
{DSFA_LICENSE_LABELS[licenseCode] || licenseCode}
</span>
)
}
function DocumentTypeBadge({ type }: { type?: string }) {
if (!type) return null
const colorMap: Record<string, string> = {
guideline: 'bg-indigo-100 text-indigo-700',
checklist: 'bg-emerald-100 text-emerald-700',
regulation: 'bg-red-100 text-red-700',
template: 'bg-orange-100 text-orange-700',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs ${colorMap[type] || 'bg-gray-100 text-gray-700'}`}>
{DSFA_DOCUMENT_TYPE_LABELS[type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS] || type}
</span>
)
}
function StatusIndicator({ status }: { status: string }) {
const statusConfig: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
green: { color: 'text-green-500', icon: <CheckCircle className="w-4 h-4" />, label: 'Aktiv' },
yellow: { color: 'text-yellow-500', icon: <Clock className="w-4 h-4" />, label: 'Ausstehend' },
red: { color: 'text-red-500', icon: <AlertCircle className="w-4 h-4" />, label: 'Fehler' },
}
const config = statusConfig[status] || statusConfig.yellow
return (
<span className={`inline-flex items-center gap-1 ${config.color}`}>
{config.icon}
<span className="text-sm">{config.label}</span>
</span>
)
}
function SourceCard({
source,
stats,
onIngest,
isIngesting
}: {
source: DSFASource
stats?: DSFASourceStats
onIngest: () => void
isIngesting: boolean
}) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{source.sourceCode}
</span>
<DocumentTypeBadge type={source.documentType} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{source.name}
</h3>
{source.organization && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{source.organization}
</p>
)}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
<div className="flex items-center gap-4 mt-3">
<LicenseBadge licenseCode={source.licenseCode} />
{stats && (
<>
<span className="text-sm text-gray-500">
{stats.documentCount} Dok.
</span>
<span className="text-sm text-gray-500">
{stats.chunkCount} Chunks
</span>
</>
)}
</div>
{source.attributionRequired && (
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300">
<strong>Attribution:</strong> {source.attributionText}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
<dl className="grid grid-cols-2 gap-3 text-sm">
{source.sourceUrl && (
<>
<dt className="text-gray-500">Quelle:</dt>
<dd>
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
Link <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
{source.licenseUrl && (
<>
<dt className="text-gray-500">Lizenz-URL:</dt>
<dd>
<a
href={source.licenseUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{source.licenseName} <ExternalLink className="w-3 h-3" />
</a>
</dd>
</>
)}
<dt className="text-gray-500">Sprache:</dt>
<dd className="uppercase">{source.language}</dd>
{stats?.lastIndexedAt && (
<>
<dt className="text-gray-500">Zuletzt indexiert:</dt>
<dd>{new Date(stats.lastIndexedAt).toLocaleString('de-DE')}</dd>
</>
)}
</dl>
<div className="mt-4 flex gap-2">
<button
onClick={onIngest}
disabled={isIngesting}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isIngesting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Neu indexieren
</button>
</div>
</div>
)}
</div>
)
}
function StatsOverview({ stats }: { stats: DSFACorpusStats }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Database className="w-5 h-5" />
Corpus-Statistik
</h2>
<StatusIndicator status={stats.qdrantStatus} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stats.totalSources}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Quellen</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.totalDocuments}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stats.totalChunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stats.qdrantPointsCount.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Vektoren</p>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Collection:</strong>{' '}
<code className="font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded">
{stats.qdrantCollection}
</code>
</p>
</div>
</div>
)
}
// ============================================================================
// MAIN PAGE
// ============================================================================
fetchSources,
fetchStats,
initializeCorpus,
triggerIngestion,
MOCK_SOURCES,
MOCK_STATS,
} from './_components/dsfa-api'
import { LicenseBadge } from './_components/DSFABadges'
import { SourceCard } from './_components/SourceCard'
import { StatsOverview } from './_components/StatsOverview'
export default function DSFADocumentManagerPage() {
const [sources, setSources] = useState<DSFASource[]>([])
@@ -461,7 +69,6 @@ export default function DSFADocumentManagerPage() {
setIsInitializing(true)
try {
await initializeCorpus()
// Reload data
const [sourcesData, statsData] = await Promise.all([
fetchSources(),
fetchStats(),
@@ -479,7 +86,6 @@ export default function DSFADocumentManagerPage() {
setIngestingSource(sourceCode)
try {
await triggerIngestion(sourceCode)
// Reload stats
const statsData = await fetchStats()
setStats(statsData)
} catch (err) {
@@ -501,7 +107,6 @@ export default function DSFADocumentManagerPage() {
return matchesSearch && matchesType
})
// Get stats by source code
const getStatsForSource = (sourceCode: string): DSFASourceStats | undefined => {
return stats?.sources.find(s => s.sourceCode === sourceCode)
}
@@ -0,0 +1,52 @@
/**
* Constants and types for ChunkBrowserQA component.
*/
export type RegGroupKey =
| 'eu_regulation'
| 'eu_directive'
| 'de_law'
| 'at_law'
| 'ch_law'
| 'national_law'
| 'bsi_standard'
| 'eu_guideline'
| 'international_standard'
| 'other'
export 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',
}
export const GROUP_ORDER: RegGroupKey[] = [
'eu_regulation', 'eu_directive', 'de_law', 'at_law', 'ch_law',
'national_law', 'bsi_standard', 'eu_guideline', 'international_standard', 'other',
]
export const COLLECTIONS = [
'bp_compliance_gesetze',
'bp_compliance_ce',
'bp_compliance_datenschutz',
'bp_dsfa_corpus',
'bp_compliance_recht',
'bp_legal_templates',
'bp_nibis_eh',
]
export const STRUCTURAL_KEYS = new Set([
'article', 'artikel', 'paragraph', 'section_title', 'section', 'chapter',
'abschnitt', 'kapitel', 'pages', 'page',
])
export const HIDDEN_KEYS = new Set([
'text', 'content', 'chunk_text', 'id', 'embedding',
])
@@ -0,0 +1,234 @@
'use client'
import React from 'react'
import { STRUCTURAL_KEYS, HIDDEN_KEYS } from './ChunkBrowserConstants'
import { getChunkText, getStructuralInfo } from './ChunkBrowserHelpers'
import { RAG_PDF_MAPPING } from './rag-pdf-mapping'
import { REGULATIONS_IN_RAG } from '../rag-constants'
interface ChunkBrowserContentProps {
selectedRegulation: string | null
docLoading: boolean
docChunks: Record<string, unknown>[]
docChunkIndex: number
docTotalChunks: number
splitViewActive: boolean
chunksPerPage: number
pdfExists: boolean | null
}
export function ChunkBrowserContent({
selectedRegulation,
docLoading,
docChunks,
docChunkIndex,
docTotalChunks,
splitViewActive,
chunksPerPage,
pdfExists,
}: ChunkBrowserContentProps) {
const currentChunk = docChunks[docChunkIndex] || null
const prevChunk = docChunkIndex > 0 ? docChunks[docChunkIndex - 1] : null
const nextChunk = docChunkIndex < docChunks.length - 1 ? docChunks[docChunkIndex + 1] : null
const structInfo = getStructuralInfo(currentChunk)
// PDF page estimation
const estimatePdfPage = (chunk: Record<string, unknown> | null, chunkIdx: number): number => {
if (chunk) {
const pages = chunk.pages as number[] | undefined
if (Array.isArray(pages) && pages.length > 0) return pages[0]
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
// 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
}
if (!selectedRegulation) {
return (
<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">&#128269;</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>
)
}
if (docLoading) {
return (
<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">&#9881;</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>
)
}
return (
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Chunk-Text Panel */}
<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">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>
<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">&#8593; 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">&#8595; 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]) => {
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>
<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">&#9888; Sehr kurz</span>
)}
{getChunkText(currentChunk).length > 2000 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; 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 &#8599;
</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">&#128196;</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>
)
}
@@ -0,0 +1,39 @@
/**
* Helper functions for ChunkBrowserQA component.
*/
import { REGULATION_INFO } from '../rag-constants'
/** Get text content from a chunk */
export function 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 */
export function 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
}
/** Regulation name lookup */
export function getRegName(code: string): string {
const reg = REGULATION_INFO.find(r => r.code === code)
return reg?.name || code
}
@@ -1,43 +1,18 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
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
}
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)
@@ -58,7 +33,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
const [chunksPerPage, setChunksPerPage] = useState(6)
const [fullscreen, setFullscreen] = useState(false)
// Collection — default to bp_compliance_ce where we have PDFs downloaded
// Collection
const [collection, setCollection] = useState('bp_compliance_ce')
// PDF existence check
@@ -72,7 +47,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
.filter(([, info]) => info.collection === collection)
.map(([code]) => code)
const groupedRegulations = React.useMemo(() => {
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: [],
@@ -81,11 +56,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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',
})
groups[groupKey].push({ code, name: reg?.name || code, type: reg?.type || 'unknown' })
}
return groups
}, [regulationsInCollection.join(',')])
@@ -96,7 +67,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
.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] = []
@@ -114,13 +84,10 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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
}
for (const code of codes) { mapped[code] = count }
}
setRegulationCounts(prev => ({ ...prev, ...mapped }))
}
@@ -166,7 +133,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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)
@@ -188,30 +154,6 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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 }
@@ -223,29 +165,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
.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)
}
// Keyboard navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && fullscreen) {
e.preventDefault()
@@ -266,6 +186,21 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
}
}, [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)
@@ -275,47 +210,8 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
})
}
// 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(() => {
const filteredRegulations = useMemo(() => {
if (!filterSearch.trim()) return groupedRegulations
const term = filterSearch.toLowerCase()
const filtered: typeof groupedRegulations = {
@@ -330,21 +226,7 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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 currentChunk = docChunks[docChunkIndex] || null
const structInfo = getStructuralInfo(currentChunk)
return (
@@ -352,323 +234,48 @@ export function ChunkBrowserQA({ apiProxy }: ChunkBrowserQAProps) {
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"
>
&#9664; 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 &#9654;
</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."
<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>
<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 ? '&#10005; Vollbild beenden' : '&#9974; 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"
<ChunkBrowserSidebar
filterSearch={filterSearch}
setFilterSearch={setFilterSearch}
countsLoading={countsLoading}
filteredRegulations={filteredRegulations}
regulationCounts={regulationCounts}
selectedRegulation={selectedRegulation}
collapsedGroups={collapsedGroups}
onSelectRegulation={handleSelectRegulation}
onToggleGroup={toggleGroup}
/>
{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">&#128269;</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">&#9881;</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">&#8593; 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">&#8595; 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">&#9888; Sehr kurz</span>
)}
{getChunkText(currentChunk).length > 2000 && (
<span className="ml-2 text-orange-500 font-medium">&#9888; 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 &#8599;
</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"
<ChunkBrowserContent
selectedRegulation={selectedRegulation}
docLoading={docLoading}
docChunks={docChunks}
docChunkIndex={docChunkIndex}
docTotalChunks={docTotalChunks}
splitViewActive={splitViewActive}
chunksPerPage={chunksPerPage}
pdfExists={pdfExists}
/>
) : (
<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">&#128196;</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>
)
@@ -0,0 +1,81 @@
'use client'
import React from 'react'
import { RegGroupKey, GROUP_LABELS, GROUP_ORDER } from './ChunkBrowserConstants'
interface ChunkBrowserSidebarProps {
filterSearch: string
setFilterSearch: (v: string) => void
countsLoading: boolean
filteredRegulations: Record<RegGroupKey, { code: string; name: string; type: string }[]>
regulationCounts: Record<string, number>
selectedRegulation: string | null
collapsedGroups: Set<string>
onSelectRegulation: (code: string) => void
onToggleGroup: (group: string) => void
}
export function ChunkBrowserSidebar({
filterSearch,
setFilterSearch,
countsLoading,
filteredRegulations,
regulationCounts,
selectedRegulation,
collapsedGroups,
onSelectRegulation,
onToggleGroup,
}: ChunkBrowserSidebarProps) {
return (
<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={() => onToggleGroup(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={() => onSelectRegulation(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() : '\u2014'}
</span>
</button>
)
})}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,142 @@
'use client'
import React from 'react'
import { COLLECTIONS } from './ChunkBrowserConstants'
import { getRegName } from './ChunkBrowserHelpers'
interface ChunkBrowserToolbarProps {
collection: string
onCollectionChange: (col: string) => void
selectedRegulation: string | null
structInfo: { article?: string; section?: string; pages?: string }
docChunkIndex: number
docTotalChunks: number
docChunksLength: number
chunksPerPage: number
setChunksPerPage: (v: number) => void
splitViewActive: boolean
setSplitViewActive: (v: boolean) => void
fullscreen: boolean
setFullscreen: (v: boolean) => void
onPrev: () => void
onNext: () => void
onJumpTo: (idx: number) => void
}
export function ChunkBrowserToolbar({
collection,
onCollectionChange,
selectedRegulation,
structInfo,
docChunkIndex,
docTotalChunks,
docChunksLength,
chunksPerPage,
setChunksPerPage,
splitViewActive,
setSplitViewActive,
fullscreen,
setFullscreen,
onPrev,
onNext,
onJumpTo,
}: ChunkBrowserToolbarProps) {
return (
<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) => onCollectionChange(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} &mdash; {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={onPrev}
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"
>
&#9664; Zurueck
</button>
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
{docChunkIndex + 1} / {docTotalChunks}
</span>
<button
onClick={onNext}
disabled={docChunkIndex >= docChunksLength - 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 &#9654;
</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) onJumpTo(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 ? '\u2715 Vollbild beenden' : '\u2716 Vollbild'}
</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,189 @@
'use client'
import type {
WebsiteContent,
HeroContent,
FeatureContent,
} from '@/lib/content-types'
// Shared styles
export const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
export const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
export interface EditorProps {
content: WebsiteContent
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
}
// =============================================================================
// Hero Editor
// =============================================================================
export function HeroEditor({ content, setContent }: EditorProps) {
function update(field: keyof HeroContent, value: string) {
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
}
return (
<div className="grid gap-3">
<div><label className={labelCls}>Badge</label><input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} /></div>
<div><label className={labelCls}>Titel</label><input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>Highlight 1</label><input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} /></div>
<div><label className={labelCls}>Highlight 2</label><input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} /></div>
</div>
<div><label className={labelCls}>Untertitel</label><textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} /></div>
<div className="grid grid-cols-3 gap-3">
<div><label className={labelCls}>CTA Primaer</label><input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} /></div>
<div><label className={labelCls}>CTA Sekundaer</label><input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} /></div>
<div><label className={labelCls}>CTA Hinweis</label><input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} /></div>
</div>
</div>
)
}
// =============================================================================
// Features Editor
// =============================================================================
export function FeaturesEditor({ content, setContent }: EditorProps) {
function update(index: number, field: keyof FeatureContent, value: string) {
setContent(c => {
if (!c) return c
const features = [...c.features]
features[index] = { ...features[index], [field]: value }
return { ...c, features }
})
}
return (
<div className="space-y-3">
{content.features.map((feature, i) => (
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="grid grid-cols-6 gap-2">
<div><label className={labelCls}>Icon</label><input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} /></div>
<div className="col-span-5"><label className={labelCls}>Titel</label><input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} /></div>
</div>
<div><label className={labelCls}>Beschreibung</label><textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} /></div>
</div>
))}
</div>
)
}
// =============================================================================
// FAQ Editor
// =============================================================================
export function FAQEditor({ content, setContent }: EditorProps) {
function updateItem(index: number, field: 'question' | 'answer', value: string) {
setContent(c => {
if (!c) return c
const faq = [...c.faq]
if (field === 'answer') { faq[index] = { ...faq[index], answer: value.split('\n') } }
else { faq[index] = { ...faq[index], question: value } }
return { ...c, faq }
})
}
function addItem() { setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c) }
function removeItem(index: number) { setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c) }
return (
<div className="space-y-3">
{content.faq.map((item, i) => (
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
<button onClick={() => removeItem(i)} className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" title="Entfernen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<div><label className={labelCls}>Frage {i + 1}</label><input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} /></div>
<div><label className={labelCls}>Antwort</label><textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} /></div>
</div>
))}
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
+ Frage hinzufuegen
</button>
</div>
)
}
// =============================================================================
// Pricing Editor
// =============================================================================
export function PricingEditor({ content, setContent }: EditorProps) {
function update(index: number, field: string, value: string | number | boolean) {
setContent(c => {
if (!c) return c
const pricing = [...c.pricing]
if (field === 'price') { pricing[index] = { ...pricing[index], price: Number(value) } }
else if (field === 'popular') { pricing[index] = { ...pricing[index], popular: Boolean(value) } }
else if (field.startsWith('features.')) {
const sub = field.replace('features.', '')
if (sub === 'included' && typeof value === 'string') { pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } } }
else { pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } } }
} else { pricing[index] = { ...pricing[index], [field]: value } }
return { ...c, pricing }
})
}
return (
<div className="space-y-4">
{content.pricing.map((plan, i) => (
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
</div>
<div className="grid grid-cols-4 gap-2">
<div><label className={labelCls}>Name</label><input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} /></div>
<div><label className={labelCls}>Preis (EUR)</label><input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} /></div>
<div><label className={labelCls}>Intervall</label><input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} /></div>
<div className="flex items-end pb-1"><label className="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" /><span className="text-xs text-slate-600">Beliebt</span></label></div>
</div>
<div><label className={labelCls}>Beschreibung</label><input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-2">
<div><label className={labelCls}>Aufgaben</label><input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} /></div>
<div><label className={labelCls}>Aufgaben-Beschreibung</label><input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} /></div>
</div>
<div><label className={labelCls}>Features (eine pro Zeile)</label><textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} /></div>
</div>
))}
</div>
)
}
// =============================================================================
// Trust Editor
// =============================================================================
export function TrustEditor({ content, setContent }: EditorProps) {
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
}
return (
<div className="grid grid-cols-3 gap-3">
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div><label className={labelCls}>Wert {i + 1}</label><input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} /></div>
<div><label className={labelCls}>Label {i + 1}</label><input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} /></div>
</div>
))}
</div>
)
}
// =============================================================================
// Testimonial Editor
// =============================================================================
export function TestimonialEditor({ content, setContent }: EditorProps) {
function update(field: 'quote' | 'author' | 'role', value: string) {
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
}
return (
<div className="space-y-3">
<div><label className={labelCls}>Zitat</label><textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} /></div>
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>Autor</label><input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} /></div>
<div><label className={labelCls}>Rolle</label><input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} /></div>
</div>
</div>
)
}
+54 -478
View File
@@ -9,17 +9,14 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import type {
WebsiteContent,
HeroContent,
FeatureContent,
FAQItem,
PricingPlan,
} from '@/lib/content-types'
import type { WebsiteContent } from '@/lib/content-types'
import {
HeroEditor, FeaturesEditor, FAQEditor, PricingEditor,
TrustEditor, TestimonialEditor,
} from './_components/SectionEditors'
const ADMIN_KEY = 'breakpilot-admin-2024'
// Section metadata for cards
const SECTIONS = [
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
@@ -35,52 +32,34 @@ type SectionKey = (typeof SECTIONS)[number]['key']
function countWords(content: WebsiteContent): number {
const texts: string[] = []
// Hero
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
// Features
content.features.forEach(f => { texts.push(f.title, f.description) })
// FAQ
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
// Pricing
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
// Trust
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
// Testimonial
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
}
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
switch (section) {
case 'hero':
return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
case 'features':
return content.features.length > 0 && content.features.every(f => f.title && f.description)
case 'faq':
return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
case 'pricing':
return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
case 'trust':
return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
case 'testimonial':
return !!(content.testimonial.quote && content.testimonial.author)
case 'hero': return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
case 'features': return content.features.length > 0 && content.features.every(f => f.title && f.description)
case 'faq': return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
case 'pricing': return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
case 'trust': return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
case 'testimonial': return !!(content.testimonial.quote && content.testimonial.author)
}
}
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
switch (section) {
case 'hero':
return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
case 'features':
return `${content.features.length} Features`
case 'faq':
return `${content.faq.length} Fragen`
case 'pricing':
return `${content.pricing.length} Plaene`
case 'trust':
return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
case 'testimonial':
return `"${content.testimonial.quote.slice(0, 40)}..."`
case 'hero': return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
case 'features': return `${content.features.length} Features`
case 'faq': return `${content.faq.length} Fragen`
case 'pricing': return `${content.pricing.length} Plaene`
case 'trust': return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
case 'testimonial': return `"${content.testimonial.quote.slice(0, 40)}..."`
}
}
@@ -96,107 +75,48 @@ export default function WebsiteManagerPage() {
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
// Load content
useEffect(() => {
loadContent()
checkWebsiteStatus()
}, [])
// Auto-dismiss messages
useEffect(() => {
if (message) {
const t = setTimeout(() => setMessage(null), 4000)
return () => clearTimeout(t)
}
}, [message])
useEffect(() => { loadContent(); checkWebsiteStatus() }, [])
useEffect(() => { if (message) { const t = setTimeout(() => setMessage(null), 4000); return () => clearTimeout(t) } }, [message])
async function loadContent() {
try {
const res = await fetch('/api/website/content')
if (res.ok) {
const data = await res.json()
setContent(data)
setOriginalContent(JSON.parse(JSON.stringify(data)))
} else {
setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' })
} finally {
setLoading(false)
}
if (res.ok) { const data = await res.json(); setContent(data); setOriginalContent(JSON.parse(JSON.stringify(data))) }
else { setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' }) }
} catch { setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' }) }
finally { setLoading(false) }
}
async function checkWebsiteStatus() {
try {
const res = await fetch('/api/website/status')
if (res.ok) {
const data = await res.json()
setWebsiteStatus(data)
}
} catch {
setWebsiteStatus({ online: false, responseTime: 0 })
}
try { const res = await fetch('/api/website/status'); if (res.ok) { setWebsiteStatus(await res.json()) } }
catch { setWebsiteStatus({ online: false, responseTime: 0 }) }
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
setSaving(true); setMessage(null)
try {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' })
setOriginalContent(JSON.parse(JSON.stringify(content)))
// Reload iframe to reflect changes
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src
}
} else {
const err = await res.json()
setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' })
}
} catch {
setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' })
} finally {
setSaving(false)
}
const res = await fetch('/api/website/content', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY }, body: JSON.stringify(content) })
if (res.ok) { setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' }); setOriginalContent(JSON.parse(JSON.stringify(content))); if (iframeRef.current) { iframeRef.current.src = iframeRef.current.src } }
else { const err = await res.json(); setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' }) }
} catch { setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' }) }
finally { setSaving(false) }
}
function resetContent() {
if (originalContent) {
setContent(JSON.parse(JSON.stringify(originalContent)))
setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' })
}
if (originalContent) { setContent(JSON.parse(JSON.stringify(originalContent))); setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' }) }
}
// Scroll iframe to section
const scrollPreview = useCallback((scrollTo: string) => {
if (!iframeRef.current?.contentWindow) return
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: scrollTo },
'*'
)
} catch {
// cross-origin fallback
}
try { iframeRef.current.contentWindow.postMessage({ type: 'scrollTo', section: scrollTo }, '*') } catch { /* cross-origin fallback */ }
}, [])
function toggleSection(key: SectionKey) {
const newExpanded = expandedSection === key ? null : key
setExpandedSection(newExpanded)
if (newExpanded) {
const section = SECTIONS.find(s => s.key === newExpanded)
if (section) scrollPreview(section.scrollTo)
if (newExpanded) { const section = SECTIONS.find(s => s.key === newExpanded); if (section) scrollPreview(section.scrollTo) }
}
}
// ─── Render ────────────────────────────────────────────────────────────────
if (loading) {
return (
@@ -213,11 +133,7 @@ export default function WebsiteManagerPage() {
}
if (!content) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-red-600">Content konnte nicht geladen werden.</div>
</div>
)
return <div className="flex items-center justify-center py-20"><div className="text-red-600">Content konnte nicht geladen werden.</div></div>
}
const wordCount = countWords(content)
@@ -227,58 +143,29 @@ export default function WebsiteManagerPage() {
return (
<div className="space-y-4">
{/* ── Status Bar ───────────────────────────────────────────────────── */}
{/* Status Bar */}
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Website status */}
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-slate-700">
Website {websiteStatus?.online ? 'online' : 'offline'}
{websiteStatus?.online && websiteStatus.responseTime > 0 && (
<span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>
)}
{websiteStatus?.online && websiteStatus.responseTime > 0 && <span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>}
</span>
</div>
{/* Link */}
<a
href="https://macmini:3000"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1"
>
<a href="https://macmini:3000" target="_blank" rel="noopener noreferrer" className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1">
Zur Website
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</div>
<div className="flex items-center gap-3">
{message && (
<span className={`px-3 py-1 rounded-lg text-sm font-medium ${
message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{message.text}
</span>
)}
<button
onClick={resetContent}
disabled={!hasChanges}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={saveChanges}
disabled={saving || !hasChanges}
className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
{message && <span className={`px-3 py-1 rounded-lg text-sm font-medium ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>{message.text}</span>}
<button onClick={resetContent} disabled={!hasChanges} className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Reset</button>
<button onClick={saveChanges} disabled={saving || !hasChanges} className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">{saving ? 'Speichern...' : 'Speichern'}</button>
</div>
</div>
{/* ── Stats Bar ────────────────────────────────────────────────────── */}
{/* Stats Bar */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
@@ -288,33 +175,21 @@ export default function WebsiteManagerPage() {
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
<span className="text-xl">{stat.icon}</span>
<div>
<div className="text-sm font-semibold text-slate-900">{stat.value}</div>
<div className="text-xs text-slate-500">{stat.label}</div>
</div>
<div><div className="text-sm font-semibold text-slate-900">{stat.value}</div><div className="text-xs text-slate-500">{stat.label}</div></div>
</div>
))}
</div>
{/* ── Main Layout: 60/40 ───────────────────────────────────────────── */}
{/* Main Layout: 60/40 */}
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
{/* ── Left: Section Cards (3/5 = 60%) ──────────────────────────── */}
{/* Left: Section Cards */}
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
{SECTIONS.map((section) => {
const isExpanded = expandedSection === section.key
const isComplete = sectionComplete(content, section.key)
return (
<div
key={section.key}
className={`bg-white rounded-xl border transition-all ${
isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
}`}
>
{/* Card Header */}
<button
onClick={() => toggleSection(section.key)}
className="w-full px-5 py-4 flex items-center justify-between text-left"
>
<div key={section.key} className={`bg-white rounded-xl border transition-all ${isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'}`}>
<button onClick={() => toggleSection(section.key)} className="w-full px-5 py-4 flex items-center justify-between text-left">
<div className="flex items-center gap-3">
<span className="text-xl">{section.icon}</span>
<div>
@@ -330,16 +205,11 @@ export default function WebsiteManagerPage() {
) : (
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
)}
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<svg className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Inline Editor */}
{isExpanded && (
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
@@ -355,316 +225,22 @@ export default function WebsiteManagerPage() {
})}
</div>
{/* ── Right: Live Preview (2/5 = 40%) ──────────────────────────── */}
{/* Right: Live Preview */}
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
</div>
<div className="flex gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-400" /><div className="w-2.5 h-2.5 rounded-full bg-yellow-400" /><div className="w-2.5 h-2.5 rounded-full bg-green-400" /></div>
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
</div>
<button
onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }}
className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<button onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }} className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors" title="Preview neu laden">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</div>
{/* iframe */}
<div className="flex-1 relative bg-slate-100">
<iframe
ref={iframeRef}
src="https://macmini:3000/?preview=true"
className="absolute inset-0 w-full h-full border-0"
style={{
width: '166.67%',
height: '166.67%',
transform: 'scale(0.6)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
<iframe ref={iframeRef} src="https://macmini:3000/?preview=true" className="absolute inset-0 w-full h-full border-0" style={{ width: '166.67%', height: '166.67%', transform: 'scale(0.6)', transformOrigin: 'top left' }} title="Website Preview" sandbox="allow-same-origin allow-scripts" />
</div>
</div>
</div>
</div>
)
}
// ─── Section Editors ─────────────────────────────────────────────────────────
interface EditorProps {
content: WebsiteContent
setContent: React.Dispatch<React.SetStateAction<WebsiteContent | null>>
}
const inputCls = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors'
const labelCls = 'block text-xs font-medium text-slate-600 mb-1'
// ─── Hero Editor ─────────────────────────────────────────────────────────────
function HeroEditor({ content, setContent }: EditorProps) {
function update(field: keyof HeroContent, value: string) {
setContent(c => c ? { ...c, hero: { ...c.hero, [field]: value } } : c)
}
return (
<div className="grid gap-3">
<div>
<label className={labelCls}>Badge</label>
<input className={inputCls} value={content.hero.badge} onChange={e => update('badge', e.target.value)} />
</div>
<div>
<label className={labelCls}>Titel</label>
<input className={inputCls} value={content.hero.title} onChange={e => update('title', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Highlight 1</label>
<input className={inputCls} value={content.hero.titleHighlight1} onChange={e => update('titleHighlight1', e.target.value)} />
</div>
<div>
<label className={labelCls}>Highlight 2</label>
<input className={inputCls} value={content.hero.titleHighlight2} onChange={e => update('titleHighlight2', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Untertitel</label>
<textarea className={inputCls} rows={2} value={content.hero.subtitle} onChange={e => update('subtitle', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelCls}>CTA Primaer</label>
<input className={inputCls} value={content.hero.ctaPrimary} onChange={e => update('ctaPrimary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Sekundaer</label>
<input className={inputCls} value={content.hero.ctaSecondary} onChange={e => update('ctaSecondary', e.target.value)} />
</div>
<div>
<label className={labelCls}>CTA Hinweis</label>
<input className={inputCls} value={content.hero.ctaHint} onChange={e => update('ctaHint', e.target.value)} />
</div>
</div>
</div>
)
}
// ─── Features Editor ─────────────────────────────────────────────────────────
function FeaturesEditor({ content, setContent }: EditorProps) {
function update(index: number, field: keyof FeatureContent, value: string) {
setContent(c => {
if (!c) return c
const features = [...c.features]
features[index] = { ...features[index], [field]: value }
return { ...c, features }
})
}
return (
<div className="space-y-3">
{content.features.map((feature, i) => (
<div key={feature.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="grid grid-cols-6 gap-2">
<div>
<label className={labelCls}>Icon</label>
<input className={`${inputCls} text-center text-lg`} value={feature.icon} onChange={e => update(i, 'icon', e.target.value)} />
</div>
<div className="col-span-5">
<label className={labelCls}>Titel</label>
<input className={inputCls} value={feature.title} onChange={e => update(i, 'title', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<textarea className={inputCls} rows={2} value={feature.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── FAQ Editor ──────────────────────────────────────────────────────────────
function FAQEditor({ content, setContent }: EditorProps) {
function updateItem(index: number, field: 'question' | 'answer', value: string) {
setContent(c => {
if (!c) return c
const faq = [...c.faq]
if (field === 'answer') {
faq[index] = { ...faq[index], answer: value.split('\n') }
} else {
faq[index] = { ...faq[index], question: value }
}
return { ...c, faq }
})
}
function addItem() {
setContent(c => c ? { ...c, faq: [...c.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] } : c)
}
function removeItem(index: number) {
setContent(c => c ? { ...c, faq: c.faq.filter((_, i) => i !== index) } : c)
}
return (
<div className="space-y-3">
{content.faq.map((item, i) => (
<div key={i} className="bg-slate-50 rounded-lg p-3 space-y-2 relative group">
<button
onClick={() => removeItem(i)}
className="absolute top-2 right-2 p-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div>
<label className={labelCls}>Frage {i + 1}</label>
<input className={inputCls} value={item.question} onChange={e => updateItem(i, 'question', e.target.value)} />
</div>
<div>
<label className={labelCls}>Antwort</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={item.answer.join('\n')} onChange={e => updateItem(i, 'answer', e.target.value)} />
</div>
</div>
))}
<button onClick={addItem} className="w-full py-2 border-2 border-dashed border-slate-300 rounded-lg text-sm text-slate-500 hover:border-sky-400 hover:text-sky-600 transition-colors">
+ Frage hinzufuegen
</button>
</div>
)
}
// ─── Pricing Editor ──────────────────────────────────────────────────────────
function PricingEditor({ content, setContent }: EditorProps) {
function update(index: number, field: string, value: string | number | boolean) {
setContent(c => {
if (!c) return c
const pricing = [...c.pricing]
if (field === 'price') {
pricing[index] = { ...pricing[index], price: Number(value) }
} else if (field === 'popular') {
pricing[index] = { ...pricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const sub = field.replace('features.', '')
if (sub === 'included' && typeof value === 'string') {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, included: value.split('\n') } }
} else {
pricing[index] = { ...pricing[index], features: { ...pricing[index].features, [sub]: value } }
}
} else {
pricing[index] = { ...pricing[index], [field]: value }
}
return { ...c, pricing }
})
}
return (
<div className="space-y-4">
{content.pricing.map((plan, i) => (
<div key={plan.id} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-slate-800">{plan.name}</span>
{plan.popular && <span className="text-xs bg-sky-100 text-sky-700 px-1.5 py-0.5 rounded">Beliebt</span>}
</div>
<div className="grid grid-cols-4 gap-2">
<div>
<label className={labelCls}>Name</label>
<input className={inputCls} value={plan.name} onChange={e => update(i, 'name', e.target.value)} />
</div>
<div>
<label className={labelCls}>Preis (EUR)</label>
<input className={inputCls} type="number" step="0.01" value={plan.price} onChange={e => update(i, 'price', e.target.value)} />
</div>
<div>
<label className={labelCls}>Intervall</label>
<input className={inputCls} value={plan.interval} onChange={e => update(i, 'interval', e.target.value)} />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={plan.popular || false} onChange={e => update(i, 'popular', e.target.checked)} className="w-4 h-4 text-sky-600 rounded" />
<span className="text-xs text-slate-600">Beliebt</span>
</label>
</div>
</div>
<div>
<label className={labelCls}>Beschreibung</label>
<input className={inputCls} value={plan.description} onChange={e => update(i, 'description', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelCls}>Aufgaben</label>
<input className={inputCls} value={plan.features.tasks} onChange={e => update(i, 'features.tasks', e.target.value)} />
</div>
<div>
<label className={labelCls}>Aufgaben-Beschreibung</label>
<input className={inputCls} value={plan.features.taskDescription} onChange={e => update(i, 'features.taskDescription', e.target.value)} />
</div>
</div>
<div>
<label className={labelCls}>Features (eine pro Zeile)</label>
<textarea className={`${inputCls} font-mono`} rows={3} value={plan.features.included.join('\n')} onChange={e => update(i, 'features.included', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Trust Editor ────────────────────────────────────────────────────────────
function TrustEditor({ content, setContent }: EditorProps) {
function update(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', val: string) {
setContent(c => c ? { ...c, trust: { ...c.trust, [key]: { ...c.trust[key], [field]: val } } } : c)
}
return (
<div className="grid grid-cols-3 gap-3">
{(['item1', 'item2', 'item3'] as const).map((key, i) => (
<div key={key} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div>
<label className={labelCls}>Wert {i + 1}</label>
<input className={inputCls} value={content.trust[key].value} onChange={e => update(key, 'value', e.target.value)} />
</div>
<div>
<label className={labelCls}>Label {i + 1}</label>
<input className={inputCls} value={content.trust[key].label} onChange={e => update(key, 'label', e.target.value)} />
</div>
</div>
))}
</div>
)
}
// ─── Testimonial Editor ──────────────────────────────────────────────────────
function TestimonialEditor({ content, setContent }: EditorProps) {
function update(field: 'quote' | 'author' | 'role', value: string) {
setContent(c => c ? { ...c, testimonial: { ...c.testimonial, [field]: value } } : c)
}
return (
<div className="space-y-3">
<div>
<label className={labelCls}>Zitat</label>
<textarea className={inputCls} rows={3} value={content.testimonial.quote} onChange={e => update('quote', e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Autor</label>
<input className={inputCls} value={content.testimonial.author} onChange={e => update('author', e.target.value)} />
</div>
<div>
<label className={labelCls}>Rolle</label>
<input className={inputCls} value={content.testimonial.role} onChange={e => update('role', e.target.value)} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,367 @@
'use client'
import type { WebsiteContent, FeatureContent } from '@/lib/content-types'
// =============================================================================
// Features Editor
// =============================================================================
interface FeaturesEditorProps {
content: WebsiteContent
updateFeature: (index: number, field: keyof FeatureContent, value: string) => void
}
export function FeaturesEditor({ content, updateFeature }: FeaturesEditorProps) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input
type="text"
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// FAQ Editor
// =============================================================================
interface FAQEditorProps {
content: WebsiteContent
updateFAQ: (index: number, field: 'question' | 'answer', value: string | string[]) => void
addFAQ: () => void
removeFAQ: (index: number) => void
}
export function FAQEditor({ content, updateFAQ, addFAQ, removeFAQ }: FAQEditorProps) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
<button
onClick={addFAQ}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
+ Frage hinzufuegen
</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frage {index + 1}
</label>
<input
type="text"
value={item.question}
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Antwort
</label>
<textarea
value={item.answer.join('\n')}
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
<button
onClick={() => removeFAQ(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Frage entfernen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// Pricing Editor
// =============================================================================
interface PricingEditorProps {
content: WebsiteContent
updatePricing: (index: number, field: string, value: string | number | boolean) => void
}
export function PricingEditor({ content, updatePricing }: PricingEditorProps) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={plan.name}
onChange={(e) => updatePricing(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preis (EUR)
</label>
<input
type="number"
step="0.01"
value={plan.price}
onChange={(e) => updatePricing(index, 'price', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intervall
</label>
<input
type="text"
value={plan.interval}
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={plan.popular || false}
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-slate-700">Beliebt</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={plan.description}
onChange={(e) => updatePricing(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben
</label>
<input
type="text"
value={plan.features.tasks}
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben-Beschreibung
</label>
<input
type="text"
value={plan.features.taskDescription}
onChange={(e) =>
updatePricing(index, 'features.taskDescription', e.target.value)
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Features (eine pro Zeile)
</label>
<textarea
value={plan.features.included.join('\n')}
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// Other Section Editor (Trust + Testimonial)
// =============================================================================
interface OtherEditorProps {
content: WebsiteContent
setContent: (content: WebsiteContent) => void
}
export function OtherEditor({ content, setContent }: OtherEditorProps) {
return (
<div className="space-y-8">
{/* Trust Indicators */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wert {index + 1}
</label>
<input
type="text"
value={content.trust[key].value}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], value: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Label {index + 1}
</label>
<input
type="text"
value={content.trust[key].label}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], label: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea
value={content.testimonial.quote}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, quote: e.target.value },
})
}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input
type="text"
value={content.testimonial.author}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, author: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input
type="text"
value={content.testimonial.role}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, role: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
'use client'
import type { WebsiteContent, HeroContent } from '@/lib/content-types'
interface HeroEditorProps {
content: WebsiteContent
updateHero: (field: keyof HeroContent, value: string) => void
}
export function HeroEditor({ content, updateHero }: HeroEditorProps) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel (vor Highlight)
</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 1
</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 2
</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Primaer
</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Sekundaer
</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,75 @@
'use client'
import { RefObject } from 'react'
interface PreviewPanelProps {
activeTab: string
iframeRef: RefObject<HTMLIFrameElement | null>
}
export function PreviewPanel({ activeTab, iframeRef }: PreviewPanelProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`https://macmini:3000/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
{/* Section Indicator */}
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
)
}
@@ -16,6 +16,9 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
import { HeroEditor } from './_components/HeroEditor'
import { FeaturesEditor, FAQEditor, PricingEditor, OtherEditor } from './_components/ContentEditors'
import { PreviewPanel } from './_components/PreviewPanel'
// Admin Key (in production via login)
const ADMIN_KEY = 'breakpilot-admin-2024'
@@ -54,15 +57,8 @@ export default function UebersetzungenPage() {
}
}, [])
// Scroll to section on tab change
useEffect(() => {
scrollToSection(activeTab)
}, [activeTab, scrollToSection])
// Load content
useEffect(() => {
loadContent()
}, [])
useEffect(() => { scrollToSection(activeTab) }, [activeTab, scrollToSection])
useEffect(() => { loadContent() }, [])
async function loadContent() {
try {
@@ -82,20 +78,14 @@ export default function UebersetzungenPage() {
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/website/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': ADMIN_KEY,
},
headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY },
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: 'Gespeichert!' })
} else {
@@ -109,16 +99,11 @@ export default function UebersetzungenPage() {
}
}
// Hero Section update
function updateHero(field: keyof HeroContent, value: string) {
if (!content) return
setContent({
...content,
hero: { ...content.hero, [field]: value },
})
setContent({ ...content, hero: { ...content.hero, [field]: value } })
}
// Feature update
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
if (!content) return
const newFeatures = [...content.features]
@@ -126,7 +111,6 @@ export default function UebersetzungenPage() {
setContent({ ...content, features: newFeatures })
}
// FAQ update
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
if (!content) return
const newFAQ = [...content.faq]
@@ -138,23 +122,16 @@ export default function UebersetzungenPage() {
setContent({ ...content, faq: newFAQ })
}
// Add FAQ
function addFAQ() {
if (!content) return
setContent({
...content,
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
})
setContent({ ...content, faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }] })
}
// Remove FAQ
function removeFAQ(index: number) {
if (!content) return
const newFAQ = content.faq.filter((_, i) => i !== index)
setContent({ ...content, faq: newFAQ })
setContent({ ...content, faq: content.faq.filter((_, i) => i !== index) })
}
// Pricing update
function updatePricing(index: number, field: string, value: string | number | boolean) {
if (!content) return
const newPricing = [...content.pricing]
@@ -165,21 +142,9 @@ export default function UebersetzungenPage() {
} else if (field.startsWith('features.')) {
const subField = field.replace('features.', '')
if (subField === 'included' && typeof value === 'string') {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
included: value.split('\n'),
},
}
newPricing[index] = { ...newPricing[index], features: { ...newPricing[index].features, included: value.split('\n') } }
} else {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
[subField]: value,
},
}
newPricing[index] = { ...newPricing[index], features: { ...newPricing[index].features, [subField]: value } }
}
} else {
newPricing[index] = { ...newPricing[index], [field]: value }
@@ -209,13 +174,10 @@ export default function UebersetzungenPage() {
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-slate-900">Uebersetzungen</h1>
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showPreview
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
showPreview ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
>
@@ -228,13 +190,9 @@ export default function UebersetzungenPage() {
</div>
<div className="flex items-center gap-4">
{message && (
<span
className={`px-3 py-1 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
<span className={`px-3 py-1 rounded text-sm ${
message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{message.text}
</span>
)}
@@ -256,9 +214,7 @@ export default function UebersetzungenPage() {
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
activeTab === tab ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'
}`}
>
{tab === 'hero' && 'Hero'}
@@ -275,494 +231,15 @@ export default function UebersetzungenPage() {
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Editor Panel */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
{/* Hero Tab */}
{activeTab === 'hero' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Hero Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel (vor Highlight)
</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 1
</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 2
</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Primaer
</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Sekundaer
</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Features Tab */}
{activeTab === 'features' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Features</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input
type="text"
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* FAQ Tab */}
{activeTab === 'faq' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
<button
onClick={addFAQ}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
+ Frage hinzufuegen
</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frage {index + 1}
</label>
<input
type="text"
value={item.question}
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Antwort
</label>
<textarea
value={item.answer.join('\n')}
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
<button
onClick={() => removeFAQ(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Frage entfernen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Pricing Tab */}
{activeTab === 'pricing' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">Preise</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={plan.name}
onChange={(e) => updatePricing(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preis (EUR)
</label>
<input
type="number"
step="0.01"
value={plan.price}
onChange={(e) => updatePricing(index, 'price', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intervall
</label>
<input
type="text"
value={plan.interval}
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={plan.popular || false}
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded"
/>
<span className="text-sm text-slate-700">Beliebt</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={plan.description}
onChange={(e) => updatePricing(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben
</label>
<input
type="text"
value={plan.features.tasks}
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben-Beschreibung
</label>
<input
type="text"
value={plan.features.taskDescription}
onChange={(e) =>
updatePricing(index, 'features.taskDescription', e.target.value)
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Features (eine pro Zeile)
</label>
<textarea
value={plan.features.included.join('\n')}
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Other Tab */}
{activeTab === 'other' && (
<div className="space-y-8">
{/* Trust Indicators */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wert {index + 1}
</label>
<input
type="text"
value={content.trust[key].value}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], value: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Label {index + 1}
</label>
<input
type="text"
value={content.trust[key].label}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], label: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea
value={content.testimonial.quote}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, quote: e.target.value },
})
}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input
type="text"
value={content.testimonial.author}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, author: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input
type="text"
value={content.testimonial.role}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, role: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'hero' && <HeroEditor content={content} updateHero={updateHero} />}
{activeTab === 'features' && <FeaturesEditor content={content} updateFeature={updateFeature} />}
{activeTab === 'faq' && <FAQEditor content={content} updateFAQ={updateFAQ} addFAQ={addFAQ} removeFAQ={removeFAQ} />}
{activeTab === 'pricing' && <PricingEditor content={content} updatePricing={updatePricing} />}
{activeTab === 'other' && <OtherEditor content={content} setContent={setContent} />}
</div>
{/* Live Preview Panel */}
{showPreview && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">breakpilot.app</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`https://macmini:3000/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
{/* Section Indicator */}
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
)}
{showPreview && <PreviewPanel activeTab={activeTab} iframeRef={iframeRef} />}
</div>
</div>
)
+11 -670
View File
@@ -1,676 +1,17 @@
"""
Antizipations-Engine fuer proaktive Vorschlaege (Phase 8b).
Die Engine sammelt Signale aus verschiedenen Quellen und generiert
kontextbasierte Vorschlaege fuer Lehrer basierend auf definierten Regeln.
Architektur:
1. SignalCollector - Sammelt Inputs (Zeit, Nutzung, Events)
2. RuleEngine - Evaluiert Regeln gegen Signale
3. SuggestionGenerator - Generiert priorisierte Vorschlaege
Barrel re-export: all public symbols for backward compatibility.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from enum import Enum
import logging
logger = logging.getLogger(__name__)
# ==================== Enums & Types ====================
class SuggestionTone(str, Enum):
"""Ton/Dringlichkeit eines Vorschlags."""
HINT = "hint" # Sanfter Hinweis
SUGGESTION = "suggestion" # Aktiver Vorschlag
REMINDER = "reminder" # Erinnerung
URGENT = "urgent" # Dringend
class ContextType(str, Enum):
"""Typ eines aktiven Kontexts."""
EVENT_WINDOW = "event_window" # Event steht bevor
ROUTINE = "routine" # Routine heute
PHASE = "phase" # Makro-Phase bedingt
TIME = "time" # Zeitbasiert (Ferien, Wochenende)
@dataclass
class Signal:
"""Ein einzelnes Signal aus einer Quelle."""
name: str
value: Any
source: str # "calendar", "usage", "events", "routines"
@dataclass
class ActiveContext:
"""Ein aktiver Kontext der Vorschlaege beeinflusst."""
id: str
context_type: ContextType
label: str
data: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Suggestion:
"""Ein generierter Vorschlag."""
id: str
title: str
description: str
tone: SuggestionTone
action_url: Optional[str] = None
badge: Optional[str] = None # z.B. "in 7 Tagen"
priority: int = 50 # 0-100, hoeher = wichtiger
rule_id: str = ""
icon: str = "lightbulb"
@dataclass
class Signals:
"""Container fuer alle gesammelten Signale."""
# Zeit/Kalender
current_week: int = 1
weeks_since_start: int = 0
is_weekend: bool = False
is_before_holidays: bool = False
days_until_holidays: int = 999
# Makro-Phase
macro_phase: str = "onboarding"
onboarding_completed: bool = False
# Produktnutzung
classes_count: int = 0
has_classes: bool = False
has_schedule: bool = False
# Events
exams_scheduled_count: int = 0
exams_in_7_days: List[Dict] = field(default_factory=list)
exams_past_ungraded: List[Dict] = field(default_factory=list)
upcoming_events: List[Dict] = field(default_factory=list)
trips_in_30_days: List[Dict] = field(default_factory=list)
parent_evenings_soon: List[Dict] = field(default_factory=list)
# Routinen
routines_today: List[Dict] = field(default_factory=list)
has_conference_today: bool = False
# Statistiken (aus Analytics)
corrections_pending: int = 0
grades_completion_ratio: float = 0.0
# ==================== Signal Collector ====================
class SignalCollector:
"""
Sammelt Signale aus verschiedenen Quellen.
Quellen:
- TeacherContext (Makro-Phase, Schuljahr)
- SchoolyearEvents (Klausuren, Elternabende, etc.)
- RecurringRoutines (Konferenzen heute)
- Zeit/Kalender (Wochenende, Ferien)
"""
def __init__(self, db_session=None):
self.db = db_session
def collect(self, teacher_id: str) -> Signals:
"""Sammelt alle Signale fuer einen Lehrer."""
signals = Signals()
# Zeit-Signale
self._collect_time_signals(signals)
if self.db:
# Kontext-Signale
self._collect_context_signals(signals, teacher_id)
# Event-Signale
self._collect_event_signals(signals, teacher_id)
# Routine-Signale
self._collect_routine_signals(signals, teacher_id)
return signals
def _collect_time_signals(self, signals: Signals):
"""Sammelt zeitbasierte Signale."""
now = datetime.utcnow()
signals.is_weekend = now.weekday() >= 5
# TODO: Ferien-Kalender pro Bundesland integrieren
# Fuer jetzt: Dummy-Werte
signals.is_before_holidays = False
signals.days_until_holidays = 999
def _collect_context_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus dem Teacher-Kontext."""
from .repository import TeacherContextRepository
try:
repo = TeacherContextRepository(self.db)
context = repo.get_or_create(teacher_id)
signals.macro_phase = context.macro_phase.value
signals.current_week = context.current_week or 1
signals.onboarding_completed = context.onboarding_completed
signals.has_classes = context.has_classes
signals.has_schedule = context.has_schedule
signals.classes_count = 1 if context.has_classes else 0
# Wochen seit Schuljahresstart berechnen
if context.schoolyear_start:
delta = datetime.utcnow() - context.schoolyear_start
signals.weeks_since_start = max(0, delta.days // 7)
signals.is_before_holidays = context.is_before_holidays
except Exception as e:
logger.warning(f"Failed to collect context signals: {e}")
def _collect_event_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Events."""
from .repository import SchoolyearEventRepository
try:
repo = SchoolyearEventRepository(self.db)
now = datetime.utcnow()
# Alle anstehenden Events (30 Tage)
upcoming = repo.get_upcoming(teacher_id, days=30, limit=20)
signals.upcoming_events = [repo.to_dict(e) for e in upcoming]
# Klausuren in den naechsten 7 Tagen
seven_days = now + timedelta(days=7)
signals.exams_in_7_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "exam" and e.start_date <= seven_days
]
signals.exams_scheduled_count = len([
e for e in upcoming if e.event_type.value == "exam"
])
# Klassenfahrten in 30 Tagen
signals.trips_in_30_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "trip"
]
# Elternabende bald
signals.parent_evenings_soon = [
repo.to_dict(e) for e in upcoming
if e.event_type.value in ("parent_evening", "parent_consultation")
]
except Exception as e:
logger.warning(f"Failed to collect event signals: {e}")
def _collect_routine_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Routinen."""
from .repository import RecurringRoutineRepository
try:
repo = RecurringRoutineRepository(self.db)
today_routines = repo.get_today(teacher_id)
signals.routines_today = [repo.to_dict(r) for r in today_routines]
signals.has_conference_today = any(
r.routine_type.value in ("teacher_conference", "subject_conference")
for r in today_routines
from .antizipation_models import ( # noqa: F401
SuggestionTone,
ContextType,
Signal,
ActiveContext,
Suggestion,
Signals,
)
except Exception as e:
logger.warning(f"Failed to collect routine signals: {e}")
# ==================== Rule Engine ====================
@dataclass
class Rule:
"""Eine Regel die Signale zu Vorschlaegen mappt."""
id: str
name: str
description: str
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
"""Evaluiert die Regel und gibt einen Vorschlag zurueck oder None."""
raise NotImplementedError()
class R01_CreateClasses(Rule):
"""Klassen anlegen wenn noch keine vorhanden."""
def __init__(self):
super().__init__(
id="R01",
name="Klassen anlegen",
description="Empfiehlt Klassen anzulegen bei neuem Lehrer"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_classes:
return Suggestion(
id="suggest_create_classes",
title="Klassen anlegen",
description="Legen Sie Ihre Klassen an, um den vollen Funktionsumfang zu nutzen.",
tone=SuggestionTone.HINT,
priority=90,
rule_id=self.id,
icon="group_add",
action_url="/classes/new",
)
return None
class R02_PrepareRubric(Rule):
"""Erwartungshorizont erstellen wenn Klausur in 7 Tagen."""
def __init__(self):
super().__init__(
id="R02",
name="Erwartungshorizont",
description="Empfiehlt Erwartungshorizont vor Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_in_7_days:
exam = signals.exams_in_7_days[0]
# Pruefen ob Vorbereitung noch nicht erledigt
if not exam.get("preparation_done", False):
days = 7 # Vereinfacht
return Suggestion(
id=f"suggest_rubric_{exam['id'][:8]}",
title="Erwartungshorizont erstellen",
description=f"Klausur '{exam['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge=f"in {days} Tagen",
priority=80,
rule_id=self.id,
icon="assignment",
action_url=f"/exams/{exam['id']}/rubric",
)
return None
class R03_StartCorrection(Rule):
"""Korrektur starten nach Klausur."""
def __init__(self):
super().__init__(
id="R03",
name="Korrektur starten",
description="Empfiehlt Korrektur nach Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_past_ungraded:
exam = signals.exams_past_ungraded[0]
return Suggestion(
id=f"suggest_correction_{exam['id'][:8]}",
title="Korrektur-Setup starten",
description=f"Klausur '{exam['title']}' ist geschrieben.",
tone=SuggestionTone.HINT,
badge="bereit",
priority=75,
rule_id=self.id,
icon="rate_review",
action_url=f"/exams/{exam['id']}/correct",
)
return None
class R05_PrepareAgenda(Rule):
"""Agenda vorbereiten wenn Konferenz heute."""
def __init__(self):
super().__init__(
id="R05",
name="Konferenz-Agenda",
description="Empfiehlt Agenda wenn Konferenz heute"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.has_conference_today:
# Finde die Konferenz
conf = next(
(r for r in signals.routines_today
if r.get("routine_type") in ("teacher_conference", "subject_conference")),
None
)
if conf:
return Suggestion(
id="suggest_agenda",
title="Konferenz-Agenda vorbereiten",
description=f"{conf.get('title', 'Konferenz')} heute.",
tone=SuggestionTone.SUGGESTION,
badge="heute",
priority=70,
rule_id=self.id,
icon="event_note",
)
return None
class R07_PlanFirstExam(Rule):
"""Erste Klausur planen nach 4 Wochen."""
def __init__(self):
super().__init__(
id="R07",
name="Erste Arbeit planen",
description="Empfiehlt erste Klausur nach Anlaufphase"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.weeks_since_start >= 4 and
signals.exams_scheduled_count == 0 and
signals.has_classes):
return Suggestion(
id="suggest_first_exam",
title="Erste Klassenarbeit planen",
description="Nach 4 Wochen Unterricht ist ein guter Zeitpunkt fuer die erste Leistungsueberpruefung.",
tone=SuggestionTone.SUGGESTION,
priority=60,
rule_id=self.id,
icon="quiz",
action_url="/exams/new",
)
return None
class R08_CorrectionMode(Rule):
"""Korrekturmodus vor Ferien."""
def __init__(self):
super().__init__(
id="R08",
name="Ferien-Korrekturmodus",
description="Empfiehlt Korrekturen vor Ferien"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.is_before_holidays and signals.corrections_pending > 0:
return Suggestion(
id="suggest_correction_mode",
title="Ferien-Korrekturmodus",
description=f"{signals.corrections_pending} Korrekturen noch offen vor den Ferien.",
tone=SuggestionTone.REMINDER,
badge=f"{signals.days_until_holidays}d bis Ferien",
priority=65,
rule_id=self.id,
icon="grading",
)
return None
class R09_TripChecklist(Rule):
"""Klassenfahrt-Checkliste wenn Fahrt in 30 Tagen."""
def __init__(self):
super().__init__(
id="R09",
name="Klassenfahrt-Checkliste",
description="Empfiehlt Checkliste vor Klassenfahrt"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.trips_in_30_days:
trip = signals.trips_in_30_days[0]
return Suggestion(
id=f"suggest_trip_{trip['id'][:8]}",
title="Klassenfahrt-Checkliste",
description=f"'{trip['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="in 30 Tagen",
priority=55,
rule_id=self.id,
icon="luggage",
action_url=f"/trips/{trip['id']}/checklist",
)
return None
class R10_CompleteGrades(Rule):
"""Noten vervollstaendigen vor Halbjahresende."""
def __init__(self):
super().__init__(
id="R10",
name="Noten vervollstaendigen",
description="Empfiehlt Noten vor Notenschluss"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.macro_phase in ("halbjahresabschluss", "jahresabschluss") and
signals.grades_completion_ratio < 0.8):
pct = int(signals.grades_completion_ratio * 100)
return Suggestion(
id="suggest_complete_grades",
title="Noten vervollstaendigen",
description=f"Nur {pct}% der Noten eingetragen. Notenschluss naht!",
tone=SuggestionTone.REMINDER,
priority=85,
rule_id=self.id,
icon="calculate",
action_url="/grades",
)
return None
class R11_SetupSchedule(Rule):
"""Stundenplan einrichten wenn noch nicht vorhanden."""
def __init__(self):
super().__init__(
id="R11",
name="Stundenplan einrichten",
description="Empfiehlt Stundenplan-Setup"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_schedule:
return Suggestion(
id="suggest_setup_schedule",
title="Stundenplan einrichten",
description="Richten Sie Ihren Stundenplan ein fuer personalisierte Vorschlaege.",
tone=SuggestionTone.HINT,
priority=85,
rule_id=self.id,
icon="calendar_month",
action_url="/schedule/setup",
)
return None
class R12_ParentEvening(Rule):
"""Elternabend vorbereiten."""
def __init__(self):
super().__init__(
id="R12",
name="Elternabend vorbereiten",
description="Empfiehlt Vorbereitung vor Elternabend"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.parent_evenings_soon:
event = signals.parent_evenings_soon[0]
return Suggestion(
id=f"suggest_parent_{event['id'][:8]}",
title="Elternabend vorbereiten",
description=f"'{event['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="bald",
priority=65,
rule_id=self.id,
icon="family_restroom",
action_url=f"/events/{event['id']}/prepare",
)
return None
class RuleEngine:
"""
Evaluiert alle Regeln gegen die gesammelten Signale.
"""
def __init__(self):
self.rules: List[Rule] = [
R01_CreateClasses(),
R02_PrepareRubric(),
R03_StartCorrection(),
R05_PrepareAgenda(),
R07_PlanFirstExam(),
R08_CorrectionMode(),
R09_TripChecklist(),
R10_CompleteGrades(),
R11_SetupSchedule(),
R12_ParentEvening(),
]
def evaluate(self, signals: Signals) -> List[Suggestion]:
"""Evaluiert alle Regeln und gibt passende Vorschlaege zurueck."""
suggestions = []
for rule in self.rules:
try:
suggestion = rule.evaluate(signals)
if suggestion:
suggestions.append(suggestion)
except Exception as e:
logger.warning(f"Rule {rule.id} failed: {e}")
# Nach Prioritaet sortieren (hoechste zuerst)
suggestions.sort(key=lambda s: s.priority, reverse=True)
return suggestions
# ==================== Suggestion Generator ====================
class SuggestionGenerator:
"""
Hauptklasse die Signale sammelt, Regeln evaluiert und
Vorschlaege generiert.
"""
def __init__(self, db_session=None):
self.collector = SignalCollector(db_session)
self.rule_engine = RuleEngine()
def generate(self, teacher_id: str, limit: int = 5) -> Dict[str, Any]:
"""
Generiert Vorschlaege fuer einen Lehrer.
Returns:
{
"active_contexts": [...],
"suggestions": [...],
"signals_summary": {...}
}
"""
# 1. Signale sammeln
signals = self.collector.collect(teacher_id)
# 2. Regeln evaluieren
all_suggestions = self.rule_engine.evaluate(signals)
# 3. Aktive Kontexte bestimmen
active_contexts = self._determine_active_contexts(signals)
# 4. Top N Vorschlaege
top_suggestions = all_suggestions[:limit]
return {
"active_contexts": [
{
"id": ctx.id,
"type": ctx.context_type.value,
"label": ctx.label,
}
for ctx in active_contexts
],
"suggestions": [
{
"id": s.id,
"title": s.title,
"description": s.description,
"tone": s.tone.value,
"badge": s.badge,
"priority": s.priority,
"icon": s.icon,
"action_url": s.action_url,
}
for s in top_suggestions
],
"signals_summary": {
"macro_phase": signals.macro_phase,
"current_week": signals.current_week,
"has_classes": signals.has_classes,
"exams_soon": len(signals.exams_in_7_days),
"routines_today": len(signals.routines_today),
},
"total_suggestions": len(all_suggestions),
}
def _determine_active_contexts(self, signals: Signals) -> List[ActiveContext]:
"""Bestimmt die aktiven Kontexte basierend auf Signalen."""
contexts = []
# Event-Kontexte
if signals.exams_in_7_days:
contexts.append(ActiveContext(
id="EXAM_IN_7_DAYS",
context_type=ContextType.EVENT_WINDOW,
label="Klausur in 7 Tagen",
))
if signals.trips_in_30_days:
contexts.append(ActiveContext(
id="TRIP_UPCOMING",
context_type=ContextType.EVENT_WINDOW,
label="Klassenfahrt geplant",
))
# Routine-Kontexte
if signals.has_conference_today:
contexts.append(ActiveContext(
id="CONFERENCE_TODAY",
context_type=ContextType.ROUTINE,
label="Konferenz heute",
))
# Zeit-Kontexte
if signals.is_weekend:
contexts.append(ActiveContext(
id="WEEKEND",
context_type=ContextType.TIME,
label="Wochenende",
))
if signals.is_before_holidays:
contexts.append(ActiveContext(
id="BEFORE_HOLIDAYS",
context_type=ContextType.TIME,
label="Vor den Ferien",
))
# Phase-Kontexte
if signals.macro_phase == "onboarding":
contexts.append(ActiveContext(
id="ONBOARDING",
context_type=ContextType.PHASE,
label="Einrichtung",
))
elif signals.macro_phase in ("halbjahresabschluss", "jahresabschluss"):
contexts.append(ActiveContext(
id="GRADE_PERIOD",
context_type=ContextType.PHASE,
label="Notenphase",
))
return contexts
from .antizipation_collector import SignalCollector # noqa: F401
from .antizipation_rules import RuleEngine # noqa: F401
from .antizipation_generator import SuggestionGenerator # noqa: F401
@@ -0,0 +1,131 @@
"""
Antizipation Engine - Signal collector.
Sammelt Signale aus verschiedenen Quellen:
- TeacherContext (Makro-Phase, Schuljahr)
- SchoolyearEvents (Klausuren, Elternabende, etc.)
- RecurringRoutines (Konferenzen heute)
- Zeit/Kalender (Wochenende, Ferien)
"""
import logging
from datetime import datetime, timedelta
from .antizipation_models import Signals
logger = logging.getLogger(__name__)
class SignalCollector:
"""
Sammelt Signale aus verschiedenen Quellen.
"""
def __init__(self, db_session=None):
self.db = db_session
def collect(self, teacher_id: str) -> Signals:
"""Sammelt alle Signale fuer einen Lehrer."""
signals = Signals()
# Zeit-Signale
self._collect_time_signals(signals)
if self.db:
# Kontext-Signale
self._collect_context_signals(signals, teacher_id)
# Event-Signale
self._collect_event_signals(signals, teacher_id)
# Routine-Signale
self._collect_routine_signals(signals, teacher_id)
return signals
def _collect_time_signals(self, signals: Signals):
"""Sammelt zeitbasierte Signale."""
now = datetime.utcnow()
signals.is_weekend = now.weekday() >= 5
# TODO: Ferien-Kalender pro Bundesland integrieren
# Fuer jetzt: Dummy-Werte
signals.is_before_holidays = False
signals.days_until_holidays = 999
def _collect_context_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus dem Teacher-Kontext."""
from .repository import TeacherContextRepository
try:
repo = TeacherContextRepository(self.db)
context = repo.get_or_create(teacher_id)
signals.macro_phase = context.macro_phase.value
signals.current_week = context.current_week or 1
signals.onboarding_completed = context.onboarding_completed
signals.has_classes = context.has_classes
signals.has_schedule = context.has_schedule
signals.classes_count = 1 if context.has_classes else 0
# Wochen seit Schuljahresstart berechnen
if context.schoolyear_start:
delta = datetime.utcnow() - context.schoolyear_start
signals.weeks_since_start = max(0, delta.days // 7)
signals.is_before_holidays = context.is_before_holidays
except Exception as e:
logger.warning(f"Failed to collect context signals: {e}")
def _collect_event_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Events."""
from .repository import SchoolyearEventRepository
try:
repo = SchoolyearEventRepository(self.db)
now = datetime.utcnow()
# Alle anstehenden Events (30 Tage)
upcoming = repo.get_upcoming(teacher_id, days=30, limit=20)
signals.upcoming_events = [repo.to_dict(e) for e in upcoming]
# Klausuren in den naechsten 7 Tagen
seven_days = now + timedelta(days=7)
signals.exams_in_7_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "exam" and e.start_date <= seven_days
]
signals.exams_scheduled_count = len([
e for e in upcoming if e.event_type.value == "exam"
])
# Klassenfahrten in 30 Tagen
signals.trips_in_30_days = [
repo.to_dict(e) for e in upcoming
if e.event_type.value == "trip"
]
# Elternabende bald
signals.parent_evenings_soon = [
repo.to_dict(e) for e in upcoming
if e.event_type.value in ("parent_evening", "parent_consultation")
]
except Exception as e:
logger.warning(f"Failed to collect event signals: {e}")
def _collect_routine_signals(self, signals: Signals, teacher_id: str):
"""Sammelt Signale aus Routinen."""
from .repository import RecurringRoutineRepository
try:
repo = RecurringRoutineRepository(self.db)
today_routines = repo.get_today(teacher_id)
signals.routines_today = [repo.to_dict(r) for r in today_routines]
signals.has_conference_today = any(
r.routine_type.value in ("teacher_conference", "subject_conference")
for r in today_routines
)
except Exception as e:
logger.warning(f"Failed to collect routine signals: {e}")
@@ -0,0 +1,136 @@
"""
Antizipation Engine - SuggestionGenerator.
Main class that collects signals, evaluates rules, and generates
prioritized suggestions for teachers.
"""
from typing import List, Dict, Any
from .antizipation_models import Signals, ActiveContext, ContextType
from .antizipation_collector import SignalCollector
from .antizipation_rules import RuleEngine
class SuggestionGenerator:
"""
Hauptklasse die Signale sammelt, Regeln evaluiert und
Vorschlaege generiert.
"""
def __init__(self, db_session=None):
self.collector = SignalCollector(db_session)
self.rule_engine = RuleEngine()
def generate(self, teacher_id: str, limit: int = 5) -> Dict[str, Any]:
"""
Generiert Vorschlaege fuer einen Lehrer.
Returns:
{
"active_contexts": [...],
"suggestions": [...],
"signals_summary": {...}
}
"""
# 1. Signale sammeln
signals = self.collector.collect(teacher_id)
# 2. Regeln evaluieren
all_suggestions = self.rule_engine.evaluate(signals)
# 3. Aktive Kontexte bestimmen
active_contexts = self._determine_active_contexts(signals)
# 4. Top N Vorschlaege
top_suggestions = all_suggestions[:limit]
return {
"active_contexts": [
{
"id": ctx.id,
"type": ctx.context_type.value,
"label": ctx.label,
}
for ctx in active_contexts
],
"suggestions": [
{
"id": s.id,
"title": s.title,
"description": s.description,
"tone": s.tone.value,
"badge": s.badge,
"priority": s.priority,
"icon": s.icon,
"action_url": s.action_url,
}
for s in top_suggestions
],
"signals_summary": {
"macro_phase": signals.macro_phase,
"current_week": signals.current_week,
"has_classes": signals.has_classes,
"exams_soon": len(signals.exams_in_7_days),
"routines_today": len(signals.routines_today),
},
"total_suggestions": len(all_suggestions),
}
def _determine_active_contexts(self, signals: Signals) -> List[ActiveContext]:
"""Bestimmt die aktiven Kontexte basierend auf Signalen."""
contexts = []
# Event-Kontexte
if signals.exams_in_7_days:
contexts.append(ActiveContext(
id="EXAM_IN_7_DAYS",
context_type=ContextType.EVENT_WINDOW,
label="Klausur in 7 Tagen",
))
if signals.trips_in_30_days:
contexts.append(ActiveContext(
id="TRIP_UPCOMING",
context_type=ContextType.EVENT_WINDOW,
label="Klassenfahrt geplant",
))
# Routine-Kontexte
if signals.has_conference_today:
contexts.append(ActiveContext(
id="CONFERENCE_TODAY",
context_type=ContextType.ROUTINE,
label="Konferenz heute",
))
# Zeit-Kontexte
if signals.is_weekend:
contexts.append(ActiveContext(
id="WEEKEND",
context_type=ContextType.TIME,
label="Wochenende",
))
if signals.is_before_holidays:
contexts.append(ActiveContext(
id="BEFORE_HOLIDAYS",
context_type=ContextType.TIME,
label="Vor den Ferien",
))
# Phase-Kontexte
if signals.macro_phase == "onboarding":
contexts.append(ActiveContext(
id="ONBOARDING",
context_type=ContextType.PHASE,
label="Einrichtung",
))
elif signals.macro_phase in ("halbjahresabschluss", "jahresabschluss"):
contexts.append(ActiveContext(
id="GRADE_PERIOD",
context_type=ContextType.PHASE,
label="Notenphase",
))
return contexts
@@ -0,0 +1,93 @@
"""
Antizipation Engine - Data models, enums, and signal container.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Optional
from enum import Enum
# ==================== Enums & Types ====================
class SuggestionTone(str, Enum):
"""Ton/Dringlichkeit eines Vorschlags."""
HINT = "hint" # Sanfter Hinweis
SUGGESTION = "suggestion" # Aktiver Vorschlag
REMINDER = "reminder" # Erinnerung
URGENT = "urgent" # Dringend
class ContextType(str, Enum):
"""Typ eines aktiven Kontexts."""
EVENT_WINDOW = "event_window" # Event steht bevor
ROUTINE = "routine" # Routine heute
PHASE = "phase" # Makro-Phase bedingt
TIME = "time" # Zeitbasiert (Ferien, Wochenende)
@dataclass
class Signal:
"""Ein einzelnes Signal aus einer Quelle."""
name: str
value: Any
source: str # "calendar", "usage", "events", "routines"
@dataclass
class ActiveContext:
"""Ein aktiver Kontext der Vorschlaege beeinflusst."""
id: str
context_type: ContextType
label: str
data: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Suggestion:
"""Ein generierter Vorschlag."""
id: str
title: str
description: str
tone: SuggestionTone
action_url: Optional[str] = None
badge: Optional[str] = None # z.B. "in 7 Tagen"
priority: int = 50 # 0-100, hoeher = wichtiger
rule_id: str = ""
icon: str = "lightbulb"
@dataclass
class Signals:
"""Container fuer alle gesammelten Signale."""
# Zeit/Kalender
current_week: int = 1
weeks_since_start: int = 0
is_weekend: bool = False
is_before_holidays: bool = False
days_until_holidays: int = 999
# Makro-Phase
macro_phase: str = "onboarding"
onboarding_completed: bool = False
# Produktnutzung
classes_count: int = 0
has_classes: bool = False
has_schedule: bool = False
# Events
exams_scheduled_count: int = 0
exams_in_7_days: List[Dict] = field(default_factory=list)
exams_past_ungraded: List[Dict] = field(default_factory=list)
upcoming_events: List[Dict] = field(default_factory=list)
trips_in_30_days: List[Dict] = field(default_factory=list)
parent_evenings_soon: List[Dict] = field(default_factory=list)
# Routinen
routines_today: List[Dict] = field(default_factory=list)
has_conference_today: bool = False
# Statistiken (aus Analytics)
corrections_pending: int = 0
grades_completion_ratio: float = 0.0
@@ -0,0 +1,340 @@
"""
Antizipation Engine - Rule definitions and RuleEngine.
Each rule evaluates signals and optionally produces a Suggestion.
"""
import logging
from dataclasses import dataclass
from typing import List, Optional
from .antizipation_models import Signals, Suggestion, SuggestionTone
logger = logging.getLogger(__name__)
# ==================== Rule Base Class ====================
@dataclass
class Rule:
"""Eine Regel die Signale zu Vorschlaegen mappt."""
id: str
name: str
description: str
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
"""Evaluiert die Regel und gibt einen Vorschlag zurueck oder None."""
raise NotImplementedError()
# ==================== Rule Implementations ====================
class R01_CreateClasses(Rule):
"""Klassen anlegen wenn noch keine vorhanden."""
def __init__(self):
super().__init__(
id="R01",
name="Klassen anlegen",
description="Empfiehlt Klassen anzulegen bei neuem Lehrer"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_classes:
return Suggestion(
id="suggest_create_classes",
title="Klassen anlegen",
description="Legen Sie Ihre Klassen an, um den vollen Funktionsumfang zu nutzen.",
tone=SuggestionTone.HINT,
priority=90,
rule_id=self.id,
icon="group_add",
action_url="/classes/new",
)
return None
class R02_PrepareRubric(Rule):
"""Erwartungshorizont erstellen wenn Klausur in 7 Tagen."""
def __init__(self):
super().__init__(
id="R02",
name="Erwartungshorizont",
description="Empfiehlt Erwartungshorizont vor Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_in_7_days:
exam = signals.exams_in_7_days[0]
# Pruefen ob Vorbereitung noch nicht erledigt
if not exam.get("preparation_done", False):
days = 7 # Vereinfacht
return Suggestion(
id=f"suggest_rubric_{exam['id'][:8]}",
title="Erwartungshorizont erstellen",
description=f"Klausur '{exam['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge=f"in {days} Tagen",
priority=80,
rule_id=self.id,
icon="assignment",
action_url=f"/exams/{exam['id']}/rubric",
)
return None
class R03_StartCorrection(Rule):
"""Korrektur starten nach Klausur."""
def __init__(self):
super().__init__(
id="R03",
name="Korrektur starten",
description="Empfiehlt Korrektur nach Klausur"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.exams_past_ungraded:
exam = signals.exams_past_ungraded[0]
return Suggestion(
id=f"suggest_correction_{exam['id'][:8]}",
title="Korrektur-Setup starten",
description=f"Klausur '{exam['title']}' ist geschrieben.",
tone=SuggestionTone.HINT,
badge="bereit",
priority=75,
rule_id=self.id,
icon="rate_review",
action_url=f"/exams/{exam['id']}/correct",
)
return None
class R05_PrepareAgenda(Rule):
"""Agenda vorbereiten wenn Konferenz heute."""
def __init__(self):
super().__init__(
id="R05",
name="Konferenz-Agenda",
description="Empfiehlt Agenda wenn Konferenz heute"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.has_conference_today:
# Finde die Konferenz
conf = next(
(r for r in signals.routines_today
if r.get("routine_type") in ("teacher_conference", "subject_conference")),
None
)
if conf:
return Suggestion(
id="suggest_agenda",
title="Konferenz-Agenda vorbereiten",
description=f"{conf.get('title', 'Konferenz')} heute.",
tone=SuggestionTone.SUGGESTION,
badge="heute",
priority=70,
rule_id=self.id,
icon="event_note",
)
return None
class R07_PlanFirstExam(Rule):
"""Erste Klausur planen nach 4 Wochen."""
def __init__(self):
super().__init__(
id="R07",
name="Erste Arbeit planen",
description="Empfiehlt erste Klausur nach Anlaufphase"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.weeks_since_start >= 4 and
signals.exams_scheduled_count == 0 and
signals.has_classes):
return Suggestion(
id="suggest_first_exam",
title="Erste Klassenarbeit planen",
description="Nach 4 Wochen Unterricht ist ein guter Zeitpunkt fuer die erste Leistungsueberpruefung.",
tone=SuggestionTone.SUGGESTION,
priority=60,
rule_id=self.id,
icon="quiz",
action_url="/exams/new",
)
return None
class R08_CorrectionMode(Rule):
"""Korrekturmodus vor Ferien."""
def __init__(self):
super().__init__(
id="R08",
name="Ferien-Korrekturmodus",
description="Empfiehlt Korrekturen vor Ferien"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.is_before_holidays and signals.corrections_pending > 0:
return Suggestion(
id="suggest_correction_mode",
title="Ferien-Korrekturmodus",
description=f"{signals.corrections_pending} Korrekturen noch offen vor den Ferien.",
tone=SuggestionTone.REMINDER,
badge=f"{signals.days_until_holidays}d bis Ferien",
priority=65,
rule_id=self.id,
icon="grading",
)
return None
class R09_TripChecklist(Rule):
"""Klassenfahrt-Checkliste wenn Fahrt in 30 Tagen."""
def __init__(self):
super().__init__(
id="R09",
name="Klassenfahrt-Checkliste",
description="Empfiehlt Checkliste vor Klassenfahrt"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.trips_in_30_days:
trip = signals.trips_in_30_days[0]
return Suggestion(
id=f"suggest_trip_{trip['id'][:8]}",
title="Klassenfahrt-Checkliste",
description=f"'{trip['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="in 30 Tagen",
priority=55,
rule_id=self.id,
icon="luggage",
action_url=f"/trips/{trip['id']}/checklist",
)
return None
class R10_CompleteGrades(Rule):
"""Noten vervollstaendigen vor Halbjahresende."""
def __init__(self):
super().__init__(
id="R10",
name="Noten vervollstaendigen",
description="Empfiehlt Noten vor Notenschluss"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if (signals.macro_phase in ("halbjahresabschluss", "jahresabschluss") and
signals.grades_completion_ratio < 0.8):
pct = int(signals.grades_completion_ratio * 100)
return Suggestion(
id="suggest_complete_grades",
title="Noten vervollstaendigen",
description=f"Nur {pct}% der Noten eingetragen. Notenschluss naht!",
tone=SuggestionTone.REMINDER,
priority=85,
rule_id=self.id,
icon="calculate",
action_url="/grades",
)
return None
class R11_SetupSchedule(Rule):
"""Stundenplan einrichten wenn noch nicht vorhanden."""
def __init__(self):
super().__init__(
id="R11",
name="Stundenplan einrichten",
description="Empfiehlt Stundenplan-Setup"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.macro_phase == "onboarding" and not signals.has_schedule:
return Suggestion(
id="suggest_setup_schedule",
title="Stundenplan einrichten",
description="Richten Sie Ihren Stundenplan ein fuer personalisierte Vorschlaege.",
tone=SuggestionTone.HINT,
priority=85,
rule_id=self.id,
icon="calendar_month",
action_url="/schedule/setup",
)
return None
class R12_ParentEvening(Rule):
"""Elternabend vorbereiten."""
def __init__(self):
super().__init__(
id="R12",
name="Elternabend vorbereiten",
description="Empfiehlt Vorbereitung vor Elternabend"
)
def evaluate(self, signals: Signals) -> Optional[Suggestion]:
if signals.parent_evenings_soon:
event = signals.parent_evenings_soon[0]
return Suggestion(
id=f"suggest_parent_{event['id'][:8]}",
title="Elternabend vorbereiten",
description=f"'{event['title']}' steht bevor.",
tone=SuggestionTone.SUGGESTION,
badge="bald",
priority=65,
rule_id=self.id,
icon="family_restroom",
action_url=f"/events/{event['id']}/prepare",
)
return None
# ==================== Rule Engine ====================
class RuleEngine:
"""
Evaluiert alle Regeln gegen die gesammelten Signale.
"""
def __init__(self):
self.rules: List[Rule] = [
R01_CreateClasses(),
R02_PrepareRubric(),
R03_StartCorrection(),
R05_PrepareAgenda(),
R07_PlanFirstExam(),
R08_CorrectionMode(),
R09_TripChecklist(),
R10_CompleteGrades(),
R11_SetupSchedule(),
R12_ParentEvening(),
]
def evaluate(self, signals: Signals) -> List[Suggestion]:
"""Evaluiert alle Regeln und gibt passende Vorschlaege zurueck."""
suggestions = []
for rule in self.rules:
try:
suggestion = rule.evaluate(signals)
if suggestion:
suggestions.append(suggestion)
except Exception as e:
logger.warning(f"Rule {rule.id} failed: {e}")
# Nach Prioritaet sortieren (hoechste zuerst)
suggestions.sort(key=lambda s: s.priority, reverse=True)
return suggestions
+17 -677
View File
@@ -1,683 +1,23 @@
"""
Correction API - REST API für Klassenarbeits-Korrektur.
Correction API - REST API fuer Klassenarbeits-Korrektur.
Workflow:
1. Upload: Gescannte Klassenarbeit hochladen
2. OCR: Text aus Handschrift extrahieren
3. Analyse: Antworten analysieren und bewerten
4. Feedback: KI-generiertes Feedback erstellen
5. Export: Korrigierte Arbeit als PDF exportieren
Integriert:
- FileProcessor für OCR
- PDFService für Export
- LLM für Analyse und Feedback
Barrel re-export: router and all public symbols.
"""
import logging
import uuid
import os
from datetime import datetime
from typing import List, Dict, Any, Optional
from enum import Enum
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
from pydantic import BaseModel, Field
# FileProcessor requires OpenCV with libGL - make optional for CI
try:
from services.file_processor import FileProcessor, ProcessingResult
_ocr_available = True
except (ImportError, OSError):
FileProcessor = None # type: ignore
ProcessingResult = None # type: ignore
_ocr_available = False
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
from services.pdf_service import PDFService, CorrectionData, StudentInfo
_pdf_available = True
except (ImportError, OSError):
PDFService = None # type: ignore
CorrectionData = None # type: ignore
StudentInfo = None # type: ignore
_pdf_available = False
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/corrections",
tags=["corrections"],
from correction_endpoints import router # noqa: F401
from correction_models import ( # noqa: F401
CorrectionStatus,
AnswerEvaluation,
CorrectionCreate,
CorrectionUpdate,
Correction,
CorrectionResponse,
OCRResponse,
AnalysisResponse,
)
# Upload directory
UPLOAD_DIR = Path("/tmp/corrections")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ============================================================================
# Enums and Models
# ============================================================================
class CorrectionStatus(str, Enum):
"""Status einer Korrektur."""
UPLOADED = "uploaded" # Datei hochgeladen
PROCESSING = "processing" # OCR läuft
OCR_COMPLETE = "ocr_complete" # OCR abgeschlossen
ANALYZING = "analyzing" # Analyse läuft
ANALYZED = "analyzed" # Analyse abgeschlossen
REVIEWING = "reviewing" # Lehrkraft prüft
COMPLETED = "completed" # Korrektur abgeschlossen
ERROR = "error" # Fehler aufgetreten
class AnswerEvaluation(BaseModel):
"""Bewertung einer einzelnen Antwort."""
question_number: int
extracted_text: str
points_possible: float
points_awarded: float
feedback: str
is_correct: bool
confidence: float # 0-1, wie sicher die OCR/Analyse ist
class CorrectionCreate(BaseModel):
"""Request zum Erstellen einer neuen Korrektur."""
student_id: str
student_name: str
class_name: str
exam_title: str
subject: str
max_points: float = Field(default=100.0, ge=0)
expected_answers: Optional[Dict[str, str]] = None # Musterlösung
class CorrectionUpdate(BaseModel):
"""Request zum Aktualisieren einer Korrektur."""
evaluations: Optional[List[AnswerEvaluation]] = None
total_points: Optional[float] = None
grade: Optional[str] = None
teacher_notes: Optional[str] = None
status: Optional[CorrectionStatus] = None
class Correction(BaseModel):
"""Eine Korrektur."""
id: str
student_id: str
student_name: str
class_name: str
exam_title: str
subject: str
max_points: float
total_points: float = 0.0
percentage: float = 0.0
grade: Optional[str] = None
status: CorrectionStatus
file_path: Optional[str] = None
extracted_text: Optional[str] = None
evaluations: List[AnswerEvaluation] = []
teacher_notes: Optional[str] = None
ai_feedback: Optional[str] = None
created_at: datetime
updated_at: datetime
class CorrectionResponse(BaseModel):
"""Response für eine Korrektur."""
success: bool
correction: Optional[Correction] = None
error: Optional[str] = None
class OCRResponse(BaseModel):
"""Response für OCR-Ergebnis."""
success: bool
extracted_text: Optional[str] = None
regions: List[Dict[str, Any]] = []
confidence: float = 0.0
error: Optional[str] = None
class AnalysisResponse(BaseModel):
"""Response für Analyse-Ergebnis."""
success: bool
evaluations: List[AnswerEvaluation] = []
total_points: float = 0.0
percentage: float = 0.0
suggested_grade: Optional[str] = None
ai_feedback: Optional[str] = None
error: Optional[str] = None
# ============================================================================
# In-Memory Storage (später durch DB ersetzen)
# ============================================================================
_corrections: Dict[str, Correction] = {}
# ============================================================================
# Helper Functions
# ============================================================================
def _calculate_grade(percentage: float) -> str:
"""Berechnet Note aus Prozent (deutsches System)."""
if percentage >= 92:
return "1"
elif percentage >= 81:
return "2"
elif percentage >= 67:
return "3"
elif percentage >= 50:
return "4"
elif percentage >= 30:
return "5"
else:
return "6"
def _generate_ai_feedback(
evaluations: List[AnswerEvaluation],
total_points: float,
max_points: float,
subject: str
) -> str:
"""Generiert KI-Feedback basierend auf Bewertung."""
# Ohne LLM: Einfaches Template-basiertes Feedback
percentage = (total_points / max_points * 100) if max_points > 0 else 0
correct_count = sum(1 for e in evaluations if e.is_correct)
total_count = len(evaluations)
if percentage >= 90:
intro = "Hervorragende Leistung!"
elif percentage >= 75:
intro = "Gute Arbeit!"
elif percentage >= 60:
intro = "Insgesamt eine solide Leistung."
elif percentage >= 50:
intro = "Die Arbeit zeigt Grundkenntnisse, aber es gibt Verbesserungsbedarf."
else:
intro = "Es sind deutliche Wissenslücken erkennbar."
# Finde Verbesserungsbereiche
weak_areas = [e for e in evaluations if not e.is_correct]
strengths = [e for e in evaluations if e.is_correct and e.confidence > 0.8]
feedback_parts = [intro]
if strengths:
feedback_parts.append(
f"Besonders gut gelöst: Aufgabe(n) {', '.join(str(s.question_number) for s in strengths[:3])}."
from correction_helpers import ( # noqa: F401
corrections_store,
calculate_grade,
generate_ai_feedback,
process_ocr,
)
if weak_areas:
feedback_parts.append(
f"Übungsbedarf bei: Aufgabe(n) {', '.join(str(w.question_number) for w in weak_areas[:3])}."
)
feedback_parts.append(
f"Ergebnis: {correct_count} von {total_count} Aufgaben korrekt ({percentage:.1f}%)."
)
return " ".join(feedback_parts)
async def _process_ocr(correction_id: str, file_path: str):
"""Background Task für OCR-Verarbeitung."""
correction = _corrections.get(correction_id)
if not correction:
return
try:
correction.status = CorrectionStatus.PROCESSING
_corrections[correction_id] = correction
# OCR durchführen
processor = FileProcessor()
result = processor.process_file(file_path)
if result.success and result.text:
correction.extracted_text = result.text
correction.status = CorrectionStatus.OCR_COMPLETE
else:
correction.status = CorrectionStatus.ERROR
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
except Exception as e:
logger.error(f"OCR error for {correction_id}: {e}")
correction.status = CorrectionStatus.ERROR
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
# ============================================================================
# API Endpoints
# ============================================================================
@router.post("/", response_model=CorrectionResponse)
async def create_correction(data: CorrectionCreate):
"""
Erstellt eine neue Korrektur.
Noch ohne Datei - diese wird separat hochgeladen.
"""
correction_id = str(uuid.uuid4())
now = datetime.utcnow()
correction = Correction(
id=correction_id,
student_id=data.student_id,
student_name=data.student_name,
class_name=data.class_name,
exam_title=data.exam_title,
subject=data.subject,
max_points=data.max_points,
status=CorrectionStatus.UPLOADED,
created_at=now,
updated_at=now
)
_corrections[correction_id] = correction
logger.info(f"Created correction {correction_id} for {data.student_name}")
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/upload", response_model=CorrectionResponse)
async def upload_exam(
correction_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
"""
Lädt gescannte Klassenarbeit hoch und startet OCR.
Unterstützte Formate: PDF, PNG, JPG, JPEG
"""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
# Validiere Dateiformat
allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"}
file_ext = Path(file.filename).suffix.lower() if file.filename else ""
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Ungültiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}"
)
# Speichere Datei
file_path = UPLOAD_DIR / f"{correction_id}{file_ext}"
try:
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
correction.file_path = str(file_path)
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
# Starte OCR im Hintergrund
background_tasks.add_task(_process_ocr, correction_id, str(file_path))
logger.info(f"Uploaded file for correction {correction_id}: {file.filename}")
return CorrectionResponse(success=True, correction=correction)
except Exception as e:
logger.error(f"Upload error: {e}")
return CorrectionResponse(success=False, error=str(e))
@router.get("/{correction_id}", response_model=CorrectionResponse)
async def get_correction(correction_id: str):
"""Ruft eine Korrektur ab."""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
return CorrectionResponse(success=True, correction=correction)
@router.get("/", response_model=Dict[str, Any])
async def list_corrections(
class_name: Optional[str] = None,
status: Optional[CorrectionStatus] = None,
limit: int = 50
):
"""Listet Korrekturen auf, optional gefiltert."""
corrections = list(_corrections.values())
if class_name:
corrections = [c for c in corrections if c.class_name == class_name]
if status:
corrections = [c for c in corrections if c.status == status]
# Sortiere nach Erstellungsdatum (neueste zuerst)
corrections.sort(key=lambda x: x.created_at, reverse=True)
return {
"total": len(corrections),
"corrections": [c.dict() for c in corrections[:limit]]
}
@router.post("/{correction_id}/analyze", response_model=AnalysisResponse)
async def analyze_correction(
correction_id: str,
expected_answers: Optional[Dict[str, str]] = None
):
"""
Analysiert die extrahierten Antworten.
Optional mit Musterlösung für automatische Bewertung.
"""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if correction.status not in [CorrectionStatus.OCR_COMPLETE, CorrectionStatus.ANALYZED]:
raise HTTPException(
status_code=400,
detail=f"Korrektur im falschen Status: {correction.status}"
)
if not correction.extracted_text:
raise HTTPException(status_code=400, detail="Kein extrahierter Text vorhanden")
try:
correction.status = CorrectionStatus.ANALYZING
_corrections[correction_id] = correction
# Einfache Analyse ohne LLM
# Teile Text in Abschnitte (simuliert Aufgabenerkennung)
text_parts = correction.extracted_text.split('\n\n')
evaluations = []
for i, part in enumerate(text_parts[:10], start=1): # Max 10 Aufgaben
if len(part.strip()) < 5:
continue
# Simulierte Bewertung
# In Produktion würde hier LLM-basierte Analyse stattfinden
expected = expected_answers.get(str(i), "") if expected_answers else ""
# Einfacher Textvergleich (in Produktion: semantischer Vergleich)
is_correct = bool(expected and expected.lower() in part.lower())
points = correction.max_points / len(text_parts) if text_parts else 0
evaluation = AnswerEvaluation(
question_number=i,
extracted_text=part[:200], # Kürzen für Response
points_possible=points,
points_awarded=points if is_correct else points * 0.5, # Teilpunkte
feedback=f"Antwort zu Aufgabe {i}" + (" korrekt." if is_correct else " mit Verbesserungsbedarf."),
is_correct=is_correct,
confidence=0.7 # Simulierte Confidence
)
evaluations.append(evaluation)
# Berechne Gesamtergebnis
total_points = sum(e.points_awarded for e in evaluations)
percentage = (total_points / correction.max_points * 100) if correction.max_points > 0 else 0
suggested_grade = _calculate_grade(percentage)
# Generiere Feedback
ai_feedback = _generate_ai_feedback(
evaluations, total_points, correction.max_points, correction.subject
)
# Aktualisiere Korrektur
correction.evaluations = evaluations
correction.total_points = total_points
correction.percentage = percentage
correction.grade = suggested_grade
correction.ai_feedback = ai_feedback
correction.status = CorrectionStatus.ANALYZED
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
logger.info(f"Analysis complete for {correction_id}: {total_points}/{correction.max_points}")
return AnalysisResponse(
success=True,
evaluations=evaluations,
total_points=total_points,
percentage=percentage,
suggested_grade=suggested_grade,
ai_feedback=ai_feedback
)
except Exception as e:
logger.error(f"Analysis error: {e}")
correction.status = CorrectionStatus.ERROR
_corrections[correction_id] = correction
return AnalysisResponse(success=False, error=str(e))
@router.put("/{correction_id}", response_model=CorrectionResponse)
async def update_correction(correction_id: str, data: CorrectionUpdate):
"""
Aktualisiert eine Korrektur.
Ermöglicht manuelle Anpassungen durch die Lehrkraft.
"""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if data.evaluations is not None:
correction.evaluations = data.evaluations
correction.total_points = sum(e.points_awarded for e in data.evaluations)
correction.percentage = (
correction.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.total_points is not None:
correction.total_points = data.total_points
correction.percentage = (
data.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.grade is not None:
correction.grade = data.grade
if data.teacher_notes is not None:
correction.teacher_notes = data.teacher_notes
if data.status is not None:
correction.status = data.status
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/complete", response_model=CorrectionResponse)
async def complete_correction(correction_id: str):
"""Markiert Korrektur als abgeschlossen."""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction.status = CorrectionStatus.COMPLETED
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
logger.info(f"Correction {correction_id} completed: {correction.grade}")
return CorrectionResponse(success=True, correction=correction)
@router.get("/{correction_id}/export-pdf")
async def export_correction_pdf(correction_id: str):
"""
Exportiert korrigierte Arbeit als PDF.
Enthält:
- Originalscan
- Bewertungen
- Feedback
- Gesamtergebnis
"""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
try:
pdf_service = PDFService()
# Erstelle CorrectionData
correction_data = CorrectionData(
student=StudentInfo(
student_id=correction.student_id,
name=correction.student_name,
class_name=correction.class_name
),
exam_title=correction.exam_title,
subject=correction.subject,
date=correction.created_at.strftime("%d.%m.%Y"),
max_points=correction.max_points,
achieved_points=correction.total_points,
grade=correction.grade or "",
percentage=correction.percentage,
corrections=[
{
"question": f"Aufgabe {e.question_number}",
"answer": e.extracted_text,
"points": f"{e.points_awarded}/{e.points_possible}",
"feedback": e.feedback
}
for e in correction.evaluations
],
teacher_notes=correction.teacher_notes or "",
ai_feedback=correction.ai_feedback or ""
)
# Generiere PDF
pdf_bytes = pdf_service.generate_correction_pdf(correction_data)
from fastapi.responses import Response
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="korrektur_{correction.student_name}_{correction.exam_title}.pdf"'
}
)
except Exception as e:
logger.error(f"PDF export error: {e}")
raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}")
@router.delete("/{correction_id}")
async def delete_correction(correction_id: str):
"""Löscht eine Korrektur."""
if correction_id not in _corrections:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction = _corrections[correction_id]
# Lösche auch die hochgeladene Datei
if correction.file_path and os.path.exists(correction.file_path):
try:
os.remove(correction.file_path)
except Exception as e:
logger.warning(f"Could not delete file {correction.file_path}: {e}")
del _corrections[correction_id]
logger.info(f"Deleted correction {correction_id}")
return {"status": "deleted", "id": correction_id}
@router.get("/class/{class_name}/summary")
async def get_class_summary(class_name: str):
"""
Gibt Zusammenfassung für eine Klasse zurück.
Enthält Statistiken über alle Korrekturen der Klasse.
"""
class_corrections = [
c for c in _corrections.values()
if c.class_name == class_name and c.status == CorrectionStatus.COMPLETED
]
if not class_corrections:
return {
"class_name": class_name,
"total_students": 0,
"average_percentage": 0,
"grade_distribution": {},
"corrections": []
}
# Berechne Statistiken
percentages = [c.percentage for c in class_corrections]
average_percentage = sum(percentages) / len(percentages) if percentages else 0
# Notenverteilung
grade_distribution = {}
for c in class_corrections:
grade = c.grade or "?"
grade_distribution[grade] = grade_distribution.get(grade, 0) + 1
return {
"class_name": class_name,
"total_students": len(class_corrections),
"average_percentage": round(average_percentage, 1),
"average_points": round(
sum(c.total_points for c in class_corrections) / len(class_corrections), 1
),
"grade_distribution": grade_distribution,
"corrections": [
{
"id": c.id,
"student_name": c.student_name,
"total_points": c.total_points,
"percentage": c.percentage,
"grade": c.grade
}
for c in sorted(class_corrections, key=lambda x: x.student_name)
]
}
@router.post("/{correction_id}/ocr/retry", response_model=CorrectionResponse)
async def retry_ocr(correction_id: str, background_tasks: BackgroundTasks):
"""
Wiederholt OCR-Verarbeitung.
Nützlich wenn erste Verarbeitung fehlgeschlagen ist.
"""
correction = _corrections.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if not correction.file_path:
raise HTTPException(status_code=400, detail="Keine Datei vorhanden")
if not os.path.exists(correction.file_path):
raise HTTPException(status_code=400, detail="Datei nicht mehr vorhanden")
# Starte OCR erneut
correction.status = CorrectionStatus.UPLOADED
correction.extracted_text = None
correction.updated_at = datetime.utcnow()
_corrections[correction_id] = correction
background_tasks.add_task(_process_ocr, correction_id, correction.file_path)
return CorrectionResponse(success=True, correction=correction)
+474
View File
@@ -0,0 +1,474 @@
"""
Correction API - REST endpoint handlers.
Workflow:
1. Upload: Gescannte Klassenarbeit hochladen
2. OCR: Text aus Handschrift extrahieren
3. Analyse: Antworten analysieren und bewerten
4. Feedback: KI-generiertes Feedback erstellen
5. Export: Korrigierte Arbeit als PDF exportieren
"""
import logging
import uuid
import os
from datetime import datetime
from typing import Dict, Any, Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
from correction_models import (
CorrectionStatus,
AnswerEvaluation,
CorrectionCreate,
CorrectionUpdate,
Correction,
CorrectionResponse,
AnalysisResponse,
UPLOAD_DIR,
)
from correction_helpers import (
corrections_store,
calculate_grade,
generate_ai_feedback,
process_ocr,
PDFService,
CorrectionData,
StudentInfo,
)
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/corrections",
tags=["corrections"],
)
# ============================================================================
# API Endpoints
# ============================================================================
@router.post("/", response_model=CorrectionResponse)
async def create_correction(data: CorrectionCreate):
"""
Erstellt eine neue Korrektur.
Noch ohne Datei - diese wird separat hochgeladen.
"""
correction_id = str(uuid.uuid4())
now = datetime.utcnow()
correction = Correction(
id=correction_id,
student_id=data.student_id,
student_name=data.student_name,
class_name=data.class_name,
exam_title=data.exam_title,
subject=data.subject,
max_points=data.max_points,
status=CorrectionStatus.UPLOADED,
created_at=now,
updated_at=now
)
corrections_store[correction_id] = correction
logger.info(f"Created correction {correction_id} for {data.student_name}")
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/upload", response_model=CorrectionResponse)
async def upload_exam(
correction_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
"""
Laedt gescannte Klassenarbeit hoch und startet OCR.
Unterstuetzte Formate: PDF, PNG, JPG, JPEG
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
# Validiere Dateiformat
allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"}
file_ext = Path(file.filename).suffix.lower() if file.filename else ""
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Ungueltiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}"
)
# Speichere Datei
file_path = UPLOAD_DIR / f"{correction_id}{file_ext}"
try:
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
correction.file_path = str(file_path)
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
# Starte OCR im Hintergrund
background_tasks.add_task(process_ocr, correction_id, str(file_path))
logger.info(f"Uploaded file for correction {correction_id}: {file.filename}")
return CorrectionResponse(success=True, correction=correction)
except Exception as e:
logger.error(f"Upload error: {e}")
return CorrectionResponse(success=False, error=str(e))
@router.get("/{correction_id}", response_model=CorrectionResponse)
async def get_correction(correction_id: str):
"""Ruft eine Korrektur ab."""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
return CorrectionResponse(success=True, correction=correction)
@router.get("/", response_model=Dict[str, Any])
async def list_corrections(
class_name: Optional[str] = None,
status: Optional[CorrectionStatus] = None,
limit: int = 50
):
"""Listet Korrekturen auf, optional gefiltert."""
corrections = list(corrections_store.values())
if class_name:
corrections = [c for c in corrections if c.class_name == class_name]
if status:
corrections = [c for c in corrections if c.status == status]
# Sortiere nach Erstellungsdatum (neueste zuerst)
corrections.sort(key=lambda x: x.created_at, reverse=True)
return {
"total": len(corrections),
"corrections": [c.dict() for c in corrections[:limit]]
}
@router.post("/{correction_id}/analyze", response_model=AnalysisResponse)
async def analyze_correction(
correction_id: str,
expected_answers: Optional[Dict[str, str]] = None
):
"""
Analysiert die extrahierten Antworten.
Optional mit Musterloesung fuer automatische Bewertung.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if correction.status not in [CorrectionStatus.OCR_COMPLETE, CorrectionStatus.ANALYZED]:
raise HTTPException(
status_code=400,
detail=f"Korrektur im falschen Status: {correction.status}"
)
if not correction.extracted_text:
raise HTTPException(status_code=400, detail="Kein extrahierter Text vorhanden")
try:
correction.status = CorrectionStatus.ANALYZING
corrections_store[correction_id] = correction
# Einfache Analyse ohne LLM
# Teile Text in Abschnitte (simuliert Aufgabenerkennung)
text_parts = correction.extracted_text.split('\n\n')
evaluations = []
for i, part in enumerate(text_parts[:10], start=1): # Max 10 Aufgaben
if len(part.strip()) < 5:
continue
# Simulierte Bewertung
# In Produktion wuerde hier LLM-basierte Analyse stattfinden
expected = expected_answers.get(str(i), "") if expected_answers else ""
# Einfacher Textvergleich (in Produktion: semantischer Vergleich)
is_correct = bool(expected and expected.lower() in part.lower())
points = correction.max_points / len(text_parts) if text_parts else 0
evaluation = AnswerEvaluation(
question_number=i,
extracted_text=part[:200], # Kuerzen fuer Response
points_possible=points,
points_awarded=points if is_correct else points * 0.5, # Teilpunkte
feedback=f"Antwort zu Aufgabe {i}" + (" korrekt." if is_correct else " mit Verbesserungsbedarf."),
is_correct=is_correct,
confidence=0.7 # Simulierte Confidence
)
evaluations.append(evaluation)
# Berechne Gesamtergebnis
total_points = sum(e.points_awarded for e in evaluations)
percentage = (total_points / correction.max_points * 100) if correction.max_points > 0 else 0
suggested_grade = calculate_grade(percentage)
# Generiere Feedback
ai_feedback = generate_ai_feedback(
evaluations, total_points, correction.max_points, correction.subject
)
# Aktualisiere Korrektur
correction.evaluations = evaluations
correction.total_points = total_points
correction.percentage = percentage
correction.grade = suggested_grade
correction.ai_feedback = ai_feedback
correction.status = CorrectionStatus.ANALYZED
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
logger.info(f"Analysis complete for {correction_id}: {total_points}/{correction.max_points}")
return AnalysisResponse(
success=True,
evaluations=evaluations,
total_points=total_points,
percentage=percentage,
suggested_grade=suggested_grade,
ai_feedback=ai_feedback
)
except Exception as e:
logger.error(f"Analysis error: {e}")
correction.status = CorrectionStatus.ERROR
corrections_store[correction_id] = correction
return AnalysisResponse(success=False, error=str(e))
@router.put("/{correction_id}", response_model=CorrectionResponse)
async def update_correction(correction_id: str, data: CorrectionUpdate):
"""
Aktualisiert eine Korrektur.
Ermoeglicht manuelle Anpassungen durch die Lehrkraft.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if data.evaluations is not None:
correction.evaluations = data.evaluations
correction.total_points = sum(e.points_awarded for e in data.evaluations)
correction.percentage = (
correction.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.total_points is not None:
correction.total_points = data.total_points
correction.percentage = (
data.total_points / correction.max_points * 100
) if correction.max_points > 0 else 0
if data.grade is not None:
correction.grade = data.grade
if data.teacher_notes is not None:
correction.teacher_notes = data.teacher_notes
if data.status is not None:
correction.status = data.status
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
return CorrectionResponse(success=True, correction=correction)
@router.post("/{correction_id}/complete", response_model=CorrectionResponse)
async def complete_correction(correction_id: str):
"""Markiert Korrektur als abgeschlossen."""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction.status = CorrectionStatus.COMPLETED
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
logger.info(f"Correction {correction_id} completed: {correction.grade}")
return CorrectionResponse(success=True, correction=correction)
@router.get("/{correction_id}/export-pdf")
async def export_correction_pdf(correction_id: str):
"""
Exportiert korrigierte Arbeit als PDF.
Enthaelt:
- Originalscan
- Bewertungen
- Feedback
- Gesamtergebnis
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
try:
pdf_service = PDFService()
# Erstelle CorrectionData
correction_data = CorrectionData(
student=StudentInfo(
student_id=correction.student_id,
name=correction.student_name,
class_name=correction.class_name
),
exam_title=correction.exam_title,
subject=correction.subject,
date=correction.created_at.strftime("%d.%m.%Y"),
max_points=correction.max_points,
achieved_points=correction.total_points,
grade=correction.grade or "",
percentage=correction.percentage,
corrections=[
{
"question": f"Aufgabe {e.question_number}",
"answer": e.extracted_text,
"points": f"{e.points_awarded}/{e.points_possible}",
"feedback": e.feedback
}
for e in correction.evaluations
],
teacher_notes=correction.teacher_notes or "",
ai_feedback=correction.ai_feedback or ""
)
# Generiere PDF
pdf_bytes = pdf_service.generate_correction_pdf(correction_data)
from fastapi.responses import Response
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="korrektur_{correction.student_name}_{correction.exam_title}.pdf"'
}
)
except Exception as e:
logger.error(f"PDF export error: {e}")
raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}")
@router.delete("/{correction_id}")
async def delete_correction(correction_id: str):
"""Loescht eine Korrektur."""
if correction_id not in corrections_store:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
correction = corrections_store[correction_id]
# Loesche auch die hochgeladene Datei
if correction.file_path and os.path.exists(correction.file_path):
try:
os.remove(correction.file_path)
except Exception as e:
logger.warning(f"Could not delete file {correction.file_path}: {e}")
del corrections_store[correction_id]
logger.info(f"Deleted correction {correction_id}")
return {"status": "deleted", "id": correction_id}
@router.get("/class/{class_name}/summary")
async def get_class_summary(class_name: str):
"""
Gibt Zusammenfassung fuer eine Klasse zurueck.
Enthaelt Statistiken ueber alle Korrekturen der Klasse.
"""
class_corrections = [
c for c in corrections_store.values()
if c.class_name == class_name and c.status == CorrectionStatus.COMPLETED
]
if not class_corrections:
return {
"class_name": class_name,
"total_students": 0,
"average_percentage": 0,
"grade_distribution": {},
"corrections": []
}
# Berechne Statistiken
percentages = [c.percentage for c in class_corrections]
average_percentage = sum(percentages) / len(percentages) if percentages else 0
# Notenverteilung
grade_distribution = {}
for c in class_corrections:
grade = c.grade or "?"
grade_distribution[grade] = grade_distribution.get(grade, 0) + 1
return {
"class_name": class_name,
"total_students": len(class_corrections),
"average_percentage": round(average_percentage, 1),
"average_points": round(
sum(c.total_points for c in class_corrections) / len(class_corrections), 1
),
"grade_distribution": grade_distribution,
"corrections": [
{
"id": c.id,
"student_name": c.student_name,
"total_points": c.total_points,
"percentage": c.percentage,
"grade": c.grade
}
for c in sorted(class_corrections, key=lambda x: x.student_name)
]
}
@router.post("/{correction_id}/ocr/retry", response_model=CorrectionResponse)
async def retry_ocr(correction_id: str, background_tasks: BackgroundTasks):
"""
Wiederholt OCR-Verarbeitung.
Nuetzlich wenn erste Verarbeitung fehlgeschlagen ist.
"""
correction = corrections_store.get(correction_id)
if not correction:
raise HTTPException(status_code=404, detail="Korrektur nicht gefunden")
if not correction.file_path:
raise HTTPException(status_code=400, detail="Keine Datei vorhanden")
if not os.path.exists(correction.file_path):
raise HTTPException(status_code=400, detail="Datei nicht mehr vorhanden")
# Starte OCR erneut
correction.status = CorrectionStatus.UPLOADED
correction.extracted_text = None
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
background_tasks.add_task(process_ocr, correction_id, correction.file_path)
return CorrectionResponse(success=True, correction=correction)
+134
View File
@@ -0,0 +1,134 @@
"""
Correction API - Helper functions for grading, feedback, and OCR processing.
"""
import logging
from typing import List, Dict
from correction_models import AnswerEvaluation, CorrectionStatus, Correction
logger = logging.getLogger(__name__)
# FileProcessor requires OpenCV with libGL - make optional for CI
try:
from services.file_processor import FileProcessor, ProcessingResult
_ocr_available = True
except (ImportError, OSError):
FileProcessor = None # type: ignore
ProcessingResult = None # type: ignore
_ocr_available = False
# PDF service requires WeasyPrint with system libraries - make optional for CI
try:
from services.pdf_service import PDFService, CorrectionData, StudentInfo
_pdf_available = True
except (ImportError, OSError):
PDFService = None # type: ignore
CorrectionData = None # type: ignore
StudentInfo = None # type: ignore
_pdf_available = False
# ============================================================================
# In-Memory Storage (spaeter durch DB ersetzen)
# ============================================================================
corrections_store: Dict[str, Correction] = {}
# ============================================================================
# Helper Functions
# ============================================================================
def calculate_grade(percentage: float) -> str:
"""Berechnet Note aus Prozent (deutsches System)."""
if percentage >= 92:
return "1"
elif percentage >= 81:
return "2"
elif percentage >= 67:
return "3"
elif percentage >= 50:
return "4"
elif percentage >= 30:
return "5"
else:
return "6"
def generate_ai_feedback(
evaluations: List[AnswerEvaluation],
total_points: float,
max_points: float,
subject: str
) -> str:
"""Generiert KI-Feedback basierend auf Bewertung."""
# Ohne LLM: Einfaches Template-basiertes Feedback
percentage = (total_points / max_points * 100) if max_points > 0 else 0
correct_count = sum(1 for e in evaluations if e.is_correct)
total_count = len(evaluations)
if percentage >= 90:
intro = "Hervorragende Leistung!"
elif percentage >= 75:
intro = "Gute Arbeit!"
elif percentage >= 60:
intro = "Insgesamt eine solide Leistung."
elif percentage >= 50:
intro = "Die Arbeit zeigt Grundkenntnisse, aber es gibt Verbesserungsbedarf."
else:
intro = "Es sind deutliche Wissensluecken erkennbar."
# Finde Verbesserungsbereiche
weak_areas = [e for e in evaluations if not e.is_correct]
strengths = [e for e in evaluations if e.is_correct and e.confidence > 0.8]
feedback_parts = [intro]
if strengths:
feedback_parts.append(
f"Besonders gut geloest: Aufgabe(n) {', '.join(str(s.question_number) for s in strengths[:3])}."
)
if weak_areas:
feedback_parts.append(
f"Uebungsbedarf bei: Aufgabe(n) {', '.join(str(w.question_number) for w in weak_areas[:3])}."
)
feedback_parts.append(
f"Ergebnis: {correct_count} von {total_count} Aufgaben korrekt ({percentage:.1f}%)."
)
return " ".join(feedback_parts)
async def process_ocr(correction_id: str, file_path: str):
"""Background Task fuer OCR-Verarbeitung."""
from datetime import datetime
correction = corrections_store.get(correction_id)
if not correction:
return
try:
correction.status = CorrectionStatus.PROCESSING
corrections_store[correction_id] = correction
# OCR durchfuehren
processor = FileProcessor()
result = processor.process_file(file_path)
if result.success and result.text:
correction.extracted_text = result.text
correction.status = CorrectionStatus.OCR_COMPLETE
else:
correction.status = CorrectionStatus.ERROR
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
except Exception as e:
logger.error(f"OCR error for {correction_id}: {e}")
correction.status = CorrectionStatus.ERROR
correction.updated_at = datetime.utcnow()
corrections_store[correction_id] = correction
+111
View File
@@ -0,0 +1,111 @@
"""
Correction API - Pydantic models and enums.
"""
from datetime import datetime
from typing import List, Dict, Any, Optional
from enum import Enum
from pathlib import Path
from pydantic import BaseModel, Field
# Upload directory
UPLOAD_DIR = Path("/tmp/corrections")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ============================================================================
# Enums and Models
# ============================================================================
class CorrectionStatus(str, Enum):
"""Status einer Korrektur."""
UPLOADED = "uploaded" # Datei hochgeladen
PROCESSING = "processing" # OCR laeuft
OCR_COMPLETE = "ocr_complete" # OCR abgeschlossen
ANALYZING = "analyzing" # Analyse laeuft
ANALYZED = "analyzed" # Analyse abgeschlossen
REVIEWING = "reviewing" # Lehrkraft prueft
COMPLETED = "completed" # Korrektur abgeschlossen
ERROR = "error" # Fehler aufgetreten
class AnswerEvaluation(BaseModel):
"""Bewertung einer einzelnen Antwort."""
question_number: int
extracted_text: str
points_possible: float
points_awarded: float
feedback: str
is_correct: bool
confidence: float # 0-1, wie sicher die OCR/Analyse ist
class CorrectionCreate(BaseModel):
"""Request zum Erstellen einer neuen Korrektur."""
student_id: str
student_name: str
class_name: str
exam_title: str
subject: str
max_points: float = Field(default=100.0, ge=0)
expected_answers: Optional[Dict[str, str]] = None # Musterloesung
class CorrectionUpdate(BaseModel):
"""Request zum Aktualisieren einer Korrektur."""
evaluations: Optional[List[AnswerEvaluation]] = None
total_points: Optional[float] = None
grade: Optional[str] = None
teacher_notes: Optional[str] = None
status: Optional[CorrectionStatus] = None
class Correction(BaseModel):
"""Eine Korrektur."""
id: str
student_id: str
student_name: str
class_name: str
exam_title: str
subject: str
max_points: float
total_points: float = 0.0
percentage: float = 0.0
grade: Optional[str] = None
status: CorrectionStatus
file_path: Optional[str] = None
extracted_text: Optional[str] = None
evaluations: List[AnswerEvaluation] = []
teacher_notes: Optional[str] = None
ai_feedback: Optional[str] = None
created_at: datetime
updated_at: datetime
class CorrectionResponse(BaseModel):
"""Response fuer eine Korrektur."""
success: bool
correction: Optional[Correction] = None
error: Optional[str] = None
class OCRResponse(BaseModel):
"""Response fuer OCR-Ergebnis."""
success: bool
extracted_text: Optional[str] = None
regions: List[Dict[str, Any]] = []
confidence: float = 0.0
error: Optional[str] = None
class AnalysisResponse(BaseModel):
"""Response fuer Analyse-Ergebnis."""
success: bool
evaluations: List[AnswerEvaluation] = []
total_points: float = 0.0
percentage: float = 0.0
suggested_grade: Optional[str] = None
ai_feedback: Optional[str] = None
error: Optional[str] = None
+17 -719
View File
@@ -3,149 +3,29 @@
# ==============================================
# Async PostgreSQL database access for game sessions
# and student learning state.
#
# Barrel re-export: all public symbols are importable from here.
import os
import json
import logging
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Optional
from .database_models import (
GAME_DB_URL,
LearningLevel,
StudentLearningState,
GameSessionRecord,
GameQuizAnswer,
Achievement,
ACHIEVEMENTS,
)
from .database_learning import LearningStateMixin
from .database_sessions import SessionsMixin
from .database_extras import ExtrasMixin
logger = logging.getLogger(__name__)
# Database URL from environment
GAME_DB_URL = os.getenv(
"DATABASE_URL",
"postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot"
)
class LearningLevel(IntEnum):
"""Learning level enum mapping to grade ranges."""
BEGINNER = 1 # Klasse 2-3
ELEMENTARY = 2 # Klasse 3-4
INTERMEDIATE = 3 # Klasse 4-5
ADVANCED = 4 # Klasse 5-6
EXPERT = 5 # Klasse 6+
@dataclass
class StudentLearningState:
"""Student learning state data model."""
id: Optional[str] = None
student_id: str = ""
overall_level: int = 3
math_level: float = 3.0
german_level: float = 3.0
english_level: float = 3.0
total_play_time_minutes: int = 0
total_sessions: int = 0
questions_answered: int = 0
questions_correct: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"student_id": self.student_id,
"overall_level": self.overall_level,
"math_level": self.math_level,
"german_level": self.german_level,
"english_level": self.english_level,
"total_play_time_minutes": self.total_play_time_minutes,
"total_sessions": self.total_sessions,
"questions_answered": self.questions_answered,
"questions_correct": self.questions_correct,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@property
def accuracy(self) -> float:
"""Calculate overall accuracy percentage."""
if self.questions_answered == 0:
return 0.0
return self.questions_correct / self.questions_answered
@dataclass
class GameSessionRecord:
"""Game session record for database storage."""
id: Optional[str] = None
student_id: str = ""
game_mode: str = "video"
duration_seconds: int = 0
distance_traveled: float = 0.0
score: int = 0
questions_answered: int = 0
questions_correct: int = 0
difficulty_level: int = 3
started_at: Optional[datetime] = None
ended_at: Optional[datetime] = None
metadata: Optional[Dict[str, Any]] = None
@dataclass
class GameQuizAnswer:
"""Individual quiz answer record."""
id: Optional[str] = None
session_id: Optional[str] = None
question_id: str = ""
subject: str = ""
difficulty: int = 3
is_correct: bool = False
answer_time_ms: int = 0
created_at: Optional[datetime] = None
@dataclass
class Achievement:
"""Achievement definition and unlock status."""
id: str
name: str
description: str
icon: str = "star"
category: str = "general" # general, streak, accuracy, time, score
threshold: int = 1
unlocked: bool = False
unlocked_at: Optional[datetime] = None
progress: int = 0
# Achievement definitions (static, not in DB)
ACHIEVEMENTS = [
# Erste Schritte
Achievement(id="first_game", name="Erste Fahrt", description="Spiele dein erstes Spiel", icon="rocket", category="general", threshold=1),
Achievement(id="five_games", name="Regelmaessiger Fahrer", description="Spiele 5 Spiele", icon="car", category="general", threshold=5),
Achievement(id="twenty_games", name="Erfahrener Pilot", description="Spiele 20 Spiele", icon="trophy", category="general", threshold=20),
# Serien
Achievement(id="streak_3", name="Guter Start", description="3 richtige Antworten hintereinander", icon="fire", category="streak", threshold=3),
Achievement(id="streak_5", name="Auf Feuer", description="5 richtige Antworten hintereinander", icon="fire", category="streak", threshold=5),
Achievement(id="streak_10", name="Unaufhaltsam", description="10 richtige Antworten hintereinander", icon="fire", category="streak", threshold=10),
# Genauigkeit
Achievement(id="perfect_game", name="Perfektes Spiel", description="100% richtig in einem Spiel (min. 5 Fragen)", icon="star", category="accuracy", threshold=100),
Achievement(id="accuracy_80", name="Scharfschuetze", description="80% Gesamtgenauigkeit (min. 50 Fragen)", icon="target", category="accuracy", threshold=80),
# Zeit
Achievement(id="play_30min", name="Ausdauer", description="30 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=30),
Achievement(id="play_60min", name="Marathon", description="60 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=60),
# Score
Achievement(id="score_5000", name="Punktejaeger", description="5.000 Punkte in einem Spiel", icon="gem", category="score", threshold=5000),
Achievement(id="score_10000", name="Highscore Hero", description="10.000 Punkte in einem Spiel", icon="crown", category="score", threshold=10000),
# Level
Achievement(id="level_up", name="Aufsteiger", description="Erreiche Level 2", icon="arrow-up", category="level", threshold=2),
Achievement(id="master", name="Meister", description="Erreiche Level 5", icon="medal", category="level", threshold=5),
]
class GameDatabase:
class GameDatabase(LearningStateMixin, SessionsMixin, ExtrasMixin):
"""
Async database access for Breakpilot Drive game data.
@@ -187,588 +67,6 @@ class GameDatabase:
if not self._connected:
await self.connect()
# ==============================================
# Learning State Methods
# ==============================================
async def get_learning_state(self, student_id: str) -> Optional[StudentLearningState]:
"""Get learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
FROM student_learning_state
WHERE student_id = $1
""",
student_id
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to get learning state: {e}")
return None
async def create_or_update_learning_state(
self,
student_id: str,
overall_level: int = 3,
math_level: float = 3.0,
german_level: float = 3.0,
english_level: float = 3.0,
) -> Optional[StudentLearningState]:
"""Create or update learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO student_learning_state (
student_id, overall_level, math_level, german_level, english_level
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id) DO UPDATE SET
overall_level = EXCLUDED.overall_level,
math_level = EXCLUDED.math_level,
german_level = EXCLUDED.german_level,
english_level = EXCLUDED.english_level,
updated_at = NOW()
RETURNING id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
""",
student_id, overall_level, math_level, german_level, english_level
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to create/update learning state: {e}")
return None
async def update_learning_stats(
self,
student_id: str,
duration_minutes: int,
questions_answered: int,
questions_correct: int,
new_level: Optional[int] = None,
) -> bool:
"""Update learning stats after a game session."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
if new_level is not None:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
overall_level = $5,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered,
questions_correct, new_level
)
else:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered, questions_correct
)
return True
except Exception as e:
logger.error(f"Failed to update learning stats: {e}")
return False
# ==============================================
# Game Session Methods
# ==============================================
async def save_game_session(
self,
student_id: str,
game_mode: str,
duration_seconds: int,
distance_traveled: float,
score: int,
questions_answered: int,
questions_correct: int,
difficulty_level: int,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Save a game session and return the session ID."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO game_sessions (
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8,
NOW() - make_interval(secs => $3), NOW(), $9)
RETURNING id
""",
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
json.dumps(metadata) if metadata else None
)
if row:
return str(row["id"])
except Exception as e:
logger.error(f"Failed to save game session: {e}")
return None
async def get_user_sessions(
self,
student_id: str,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get recent game sessions for a user."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
FROM game_sessions
WHERE student_id = $1
ORDER BY ended_at DESC
LIMIT $2
""",
student_id, limit
)
return [
{
"session_id": str(row["id"]),
"user_id": str(row["student_id"]),
"game_mode": row["game_mode"],
"duration_seconds": row["duration_seconds"],
"distance_traveled": float(row["distance_traveled"]) if row["distance_traveled"] else 0.0,
"score": row["score"],
"questions_answered": row["questions_answered"],
"questions_correct": row["questions_correct"],
"difficulty_level": row["difficulty_level"],
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get user sessions: {e}")
return []
async def get_leaderboard(
self,
timeframe: str = "day",
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get leaderboard data."""
await self._ensure_connected()
if not self._pool:
return []
# Timeframe filter
timeframe_sql = {
"day": "ended_at > NOW() - INTERVAL '1 day'",
"week": "ended_at > NOW() - INTERVAL '7 days'",
"month": "ended_at > NOW() - INTERVAL '30 days'",
"all": "1=1",
}.get(timeframe, "1=1")
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT student_id, SUM(score) as total_score
FROM game_sessions
WHERE {timeframe_sql}
GROUP BY student_id
ORDER BY total_score DESC
LIMIT $1
""",
limit
)
return [
{
"rank": i + 1,
"user_id": str(row["student_id"]),
"total_score": int(row["total_score"]),
}
for i, row in enumerate(rows)
]
except Exception as e:
logger.error(f"Failed to get leaderboard: {e}")
return []
# ==============================================
# Quiz Answer Methods
# ==============================================
async def save_quiz_answer(
self,
session_id: str,
question_id: str,
subject: str,
difficulty: int,
is_correct: bool,
answer_time_ms: int,
) -> bool:
"""Save an individual quiz answer."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO game_quiz_answers (
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
) VALUES ($1, $2, $3, $4, $5, $6)
""",
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
)
return True
except Exception as e:
logger.error(f"Failed to save quiz answer: {e}")
return False
async def get_subject_stats(
self,
student_id: str
) -> Dict[str, Dict[str, Any]]:
"""Get per-subject statistics for a student."""
await self._ensure_connected()
if not self._pool:
return {}
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
qa.subject,
COUNT(*) as total,
SUM(CASE WHEN qa.is_correct THEN 1 ELSE 0 END) as correct,
AVG(qa.answer_time_ms) as avg_time_ms
FROM game_quiz_answers qa
JOIN game_sessions gs ON qa.session_id = gs.id
WHERE gs.student_id = $1
GROUP BY qa.subject
""",
student_id
)
return {
row["subject"]: {
"total": row["total"],
"correct": row["correct"],
"accuracy": row["correct"] / row["total"] if row["total"] > 0 else 0.0,
"avg_time_ms": int(row["avg_time_ms"]) if row["avg_time_ms"] else 0,
}
for row in rows
}
except Exception as e:
logger.error(f"Failed to get subject stats: {e}")
return {}
# ==============================================
# Extended Leaderboard Methods
# ==============================================
async def get_class_leaderboard(
self,
class_id: str,
timeframe: str = "week",
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get leaderboard filtered by class.
Note: Requires class_id to be stored in user metadata or
a separate class_memberships table. For now, this is a
placeholder that can be extended.
"""
# For now, fall back to regular leaderboard
# TODO: Join with class_memberships table when available
return await self.get_leaderboard(timeframe, limit)
async def get_leaderboard_with_names(
self,
timeframe: str = "day",
limit: int = 10,
anonymize: bool = True
) -> List[Dict[str, Any]]:
"""Get leaderboard with anonymized display names."""
leaderboard = await self.get_leaderboard(timeframe, limit)
# Anonymize names for privacy (e.g., "Spieler 1", "Spieler 2")
if anonymize:
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
else:
# In production: Join with users table to get real names
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
return leaderboard
# ==============================================
# Parent Dashboard Methods
# ==============================================
async def get_children_stats(
self,
children_ids: List[str]
) -> List[Dict[str, Any]]:
"""Get stats for multiple children (parent dashboard)."""
if not children_ids:
return []
results = []
for child_id in children_ids:
state = await self.get_learning_state(child_id)
sessions = await self.get_user_sessions(child_id, limit=5)
results.append({
"student_id": child_id,
"learning_state": state.to_dict() if state else None,
"recent_sessions": sessions,
"has_played": state is not None and state.total_sessions > 0,
})
return results
async def get_progress_over_time(
self,
student_id: str,
days: int = 30
) -> List[Dict[str, Any]]:
"""Get learning progress over time for charts."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
DATE(ended_at) as date,
COUNT(*) as sessions,
SUM(score) as total_score,
SUM(questions_answered) as questions,
SUM(questions_correct) as correct,
AVG(difficulty_level) as avg_difficulty
FROM game_sessions
WHERE student_id = $1
AND ended_at > NOW() - make_interval(days => $2)
GROUP BY DATE(ended_at)
ORDER BY date ASC
""",
student_id, days
)
return [
{
"date": row["date"].isoformat(),
"sessions": row["sessions"],
"total_score": int(row["total_score"]),
"questions": row["questions"],
"correct": row["correct"],
"accuracy": row["correct"] / row["questions"] if row["questions"] > 0 else 0,
"avg_difficulty": float(row["avg_difficulty"]) if row["avg_difficulty"] else 3.0,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get progress over time: {e}")
return []
# ==============================================
# Achievement Methods
# ==============================================
async def get_student_achievements(
self,
student_id: str
) -> List[Achievement]:
"""Get achievements with unlock status for a student."""
await self._ensure_connected()
# Get student stats for progress calculation
state = await self.get_learning_state(student_id)
# Calculate progress for each achievement
achievements = []
for a in ACHIEVEMENTS:
achievement = Achievement(
id=a.id,
name=a.name,
description=a.description,
icon=a.icon,
category=a.category,
threshold=a.threshold,
)
# Calculate progress based on category
if state:
if a.category == "general":
achievement.progress = state.total_sessions
achievement.unlocked = state.total_sessions >= a.threshold
elif a.category == "time":
achievement.progress = state.total_play_time_minutes
achievement.unlocked = state.total_play_time_minutes >= a.threshold
elif a.category == "level":
achievement.progress = state.overall_level
achievement.unlocked = state.overall_level >= a.threshold
elif a.category == "accuracy":
if a.id == "accuracy_80" and state.questions_answered >= 50:
achievement.progress = int(state.accuracy * 100)
achievement.unlocked = state.accuracy >= 0.8
achievements.append(achievement)
# Check DB for unlocked achievements (streak, score, perfect game)
if self._pool:
try:
async with self._pool.acquire() as conn:
# Check for score achievements
max_score = await conn.fetchval(
"SELECT MAX(score) FROM game_sessions WHERE student_id = $1",
student_id
)
if max_score:
for a in achievements:
if a.category == "score":
a.progress = max_score
a.unlocked = max_score >= a.threshold
# Check for perfect game
perfect = await conn.fetchval(
"""
SELECT COUNT(*) FROM game_sessions
WHERE student_id = $1
AND questions_answered >= 5
AND questions_correct = questions_answered
""",
student_id
)
for a in achievements:
if a.id == "perfect_game":
a.progress = 100 if perfect and perfect > 0 else 0
a.unlocked = perfect is not None and perfect > 0
except Exception as e:
logger.error(f"Failed to check achievements: {e}")
return achievements
async def check_new_achievements(
self,
student_id: str,
session_score: int,
session_accuracy: float,
streak: int
) -> List[Achievement]:
"""
Check for newly unlocked achievements after a session.
Returns list of newly unlocked achievements.
"""
all_achievements = await self.get_student_achievements(student_id)
newly_unlocked = []
for a in all_achievements:
# Check streak achievements
if a.category == "streak" and streak >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check score achievements
if a.category == "score" and session_score >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check perfect game
if a.id == "perfect_game" and session_accuracy == 1.0:
if not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
return newly_unlocked
# Global database instance
_game_db: Optional[GameDatabase] = None
+236
View File
@@ -0,0 +1,236 @@
# ==============================================
# Breakpilot Drive - Extended DB Methods
# ==============================================
# Leaderboard extensions, parent dashboard, achievements, progress.
import logging
from typing import List, Dict, Any
from .database_models import Achievement, ACHIEVEMENTS
logger = logging.getLogger(__name__)
class ExtrasMixin:
"""Mixin providing leaderboard, parent dashboard, and achievement methods."""
async def get_class_leaderboard(
self,
class_id: str,
timeframe: str = "week",
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get leaderboard filtered by class.
Note: Requires class_id to be stored in user metadata or
a separate class_memberships table. For now, this is a
placeholder that can be extended.
"""
# For now, fall back to regular leaderboard
# TODO: Join with class_memberships table when available
return await self.get_leaderboard(timeframe, limit)
async def get_leaderboard_with_names(
self,
timeframe: str = "day",
limit: int = 10,
anonymize: bool = True
) -> List[Dict[str, Any]]:
"""Get leaderboard with anonymized display names."""
leaderboard = await self.get_leaderboard(timeframe, limit)
# Anonymize names for privacy (e.g., "Spieler 1", "Spieler 2")
if anonymize:
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
else:
# In production: Join with users table to get real names
for entry in leaderboard:
entry["display_name"] = f"Spieler {entry['rank']}"
return leaderboard
# ==============================================
# Parent Dashboard Methods
# ==============================================
async def get_children_stats(
self,
children_ids: List[str]
) -> List[Dict[str, Any]]:
"""Get stats for multiple children (parent dashboard)."""
if not children_ids:
return []
results = []
for child_id in children_ids:
state = await self.get_learning_state(child_id)
sessions = await self.get_user_sessions(child_id, limit=5)
results.append({
"student_id": child_id,
"learning_state": state.to_dict() if state else None,
"recent_sessions": sessions,
"has_played": state is not None and state.total_sessions > 0,
})
return results
async def get_progress_over_time(
self,
student_id: str,
days: int = 30
) -> List[Dict[str, Any]]:
"""Get learning progress over time for charts."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
DATE(ended_at) as date,
COUNT(*) as sessions,
SUM(score) as total_score,
SUM(questions_answered) as questions,
SUM(questions_correct) as correct,
AVG(difficulty_level) as avg_difficulty
FROM game_sessions
WHERE student_id = $1
AND ended_at > NOW() - make_interval(days => $2)
GROUP BY DATE(ended_at)
ORDER BY date ASC
""",
student_id, days
)
return [
{
"date": row["date"].isoformat(),
"sessions": row["sessions"],
"total_score": int(row["total_score"]),
"questions": row["questions"],
"correct": row["correct"],
"accuracy": row["correct"] / row["questions"] if row["questions"] > 0 else 0,
"avg_difficulty": float(row["avg_difficulty"]) if row["avg_difficulty"] else 3.0,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get progress over time: {e}")
return []
# ==============================================
# Achievement Methods
# ==============================================
async def get_student_achievements(
self,
student_id: str
) -> List[Achievement]:
"""Get achievements with unlock status for a student."""
await self._ensure_connected()
# Get student stats for progress calculation
state = await self.get_learning_state(student_id)
# Calculate progress for each achievement
achievements = []
for a in ACHIEVEMENTS:
achievement = Achievement(
id=a.id,
name=a.name,
description=a.description,
icon=a.icon,
category=a.category,
threshold=a.threshold,
)
# Calculate progress based on category
if state:
if a.category == "general":
achievement.progress = state.total_sessions
achievement.unlocked = state.total_sessions >= a.threshold
elif a.category == "time":
achievement.progress = state.total_play_time_minutes
achievement.unlocked = state.total_play_time_minutes >= a.threshold
elif a.category == "level":
achievement.progress = state.overall_level
achievement.unlocked = state.overall_level >= a.threshold
elif a.category == "accuracy":
if a.id == "accuracy_80" and state.questions_answered >= 50:
achievement.progress = int(state.accuracy * 100)
achievement.unlocked = state.accuracy >= 0.8
achievements.append(achievement)
# Check DB for unlocked achievements (streak, score, perfect game)
if self._pool:
try:
async with self._pool.acquire() as conn:
# Check for score achievements
max_score = await conn.fetchval(
"SELECT MAX(score) FROM game_sessions WHERE student_id = $1",
student_id
)
if max_score:
for a in achievements:
if a.category == "score":
a.progress = max_score
a.unlocked = max_score >= a.threshold
# Check for perfect game
perfect = await conn.fetchval(
"""
SELECT COUNT(*) FROM game_sessions
WHERE student_id = $1
AND questions_answered >= 5
AND questions_correct = questions_answered
""",
student_id
)
for a in achievements:
if a.id == "perfect_game":
a.progress = 100 if perfect and perfect > 0 else 0
a.unlocked = perfect is not None and perfect > 0
except Exception as e:
logger.error(f"Failed to check achievements: {e}")
return achievements
async def check_new_achievements(
self,
student_id: str,
session_score: int,
session_accuracy: float,
streak: int
) -> List[Achievement]:
"""
Check for newly unlocked achievements after a session.
Returns list of newly unlocked achievements.
"""
all_achievements = await self.get_student_achievements(student_id)
newly_unlocked = []
for a in all_achievements:
# Check streak achievements
if a.category == "streak" and streak >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check score achievements
if a.category == "score" and session_score >= a.threshold and not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
# Check perfect game
if a.id == "perfect_game" and session_accuracy == 1.0:
if not a.unlocked:
a.unlocked = True
newly_unlocked.append(a)
return newly_unlocked
+156
View File
@@ -0,0 +1,156 @@
# ==============================================
# Breakpilot Drive - Learning State DB Methods
# ==============================================
# Methods for reading/writing student learning state.
import logging
from typing import Optional
from .database_models import StudentLearningState
logger = logging.getLogger(__name__)
class LearningStateMixin:
"""Mixin providing learning state database methods for GameDatabase."""
async def get_learning_state(self, student_id: str) -> Optional[StudentLearningState]:
"""Get learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
FROM student_learning_state
WHERE student_id = $1
""",
student_id
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to get learning state: {e}")
return None
async def create_or_update_learning_state(
self,
student_id: str,
overall_level: int = 3,
math_level: float = 3.0,
german_level: float = 3.0,
english_level: float = 3.0,
) -> Optional[StudentLearningState]:
"""Create or update learning state for a student."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO student_learning_state (
student_id, overall_level, math_level, german_level, english_level
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (student_id) DO UPDATE SET
overall_level = EXCLUDED.overall_level,
math_level = EXCLUDED.math_level,
german_level = EXCLUDED.german_level,
english_level = EXCLUDED.english_level,
updated_at = NOW()
RETURNING id, student_id, overall_level, math_level, german_level,
english_level, total_play_time_minutes, total_sessions,
questions_answered, questions_correct, created_at, updated_at
""",
student_id, overall_level, math_level, german_level, english_level
)
if row:
return StudentLearningState(
id=str(row["id"]),
student_id=str(row["student_id"]),
overall_level=row["overall_level"],
math_level=float(row["math_level"]),
german_level=float(row["german_level"]),
english_level=float(row["english_level"]),
total_play_time_minutes=row["total_play_time_minutes"],
total_sessions=row["total_sessions"],
questions_answered=row["questions_answered"] or 0,
questions_correct=row["questions_correct"] or 0,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except Exception as e:
logger.error(f"Failed to create/update learning state: {e}")
return None
async def update_learning_stats(
self,
student_id: str,
duration_minutes: int,
questions_answered: int,
questions_correct: int,
new_level: Optional[int] = None,
) -> bool:
"""Update learning stats after a game session."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
if new_level is not None:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
overall_level = $5,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered,
questions_correct, new_level
)
else:
await conn.execute(
"""
UPDATE student_learning_state SET
total_play_time_minutes = total_play_time_minutes + $2,
total_sessions = total_sessions + 1,
questions_answered = COALESCE(questions_answered, 0) + $3,
questions_correct = COALESCE(questions_correct, 0) + $4,
updated_at = NOW()
WHERE student_id = $1
""",
student_id, duration_minutes, questions_answered, questions_correct
)
return True
except Exception as e:
logger.error(f"Failed to update learning stats: {e}")
return False
+143
View File
@@ -0,0 +1,143 @@
# ==============================================
# Breakpilot Drive - Game Database Models
# ==============================================
# Data models, enums, and achievement definitions.
import os
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from dataclasses import dataclass
from enum import IntEnum
logger = logging.getLogger(__name__)
# Database URL from environment
GAME_DB_URL = os.getenv(
"DATABASE_URL",
"postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot"
)
class LearningLevel(IntEnum):
"""Learning level enum mapping to grade ranges."""
BEGINNER = 1 # Klasse 2-3
ELEMENTARY = 2 # Klasse 3-4
INTERMEDIATE = 3 # Klasse 4-5
ADVANCED = 4 # Klasse 5-6
EXPERT = 5 # Klasse 6+
@dataclass
class StudentLearningState:
"""Student learning state data model."""
id: Optional[str] = None
student_id: str = ""
overall_level: int = 3
math_level: float = 3.0
german_level: float = 3.0
english_level: float = 3.0
total_play_time_minutes: int = 0
total_sessions: int = 0
questions_answered: int = 0
questions_correct: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"student_id": self.student_id,
"overall_level": self.overall_level,
"math_level": self.math_level,
"german_level": self.german_level,
"english_level": self.english_level,
"total_play_time_minutes": self.total_play_time_minutes,
"total_sessions": self.total_sessions,
"questions_answered": self.questions_answered,
"questions_correct": self.questions_correct,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@property
def accuracy(self) -> float:
"""Calculate overall accuracy percentage."""
if self.questions_answered == 0:
return 0.0
return self.questions_correct / self.questions_answered
@dataclass
class GameSessionRecord:
"""Game session record for database storage."""
id: Optional[str] = None
student_id: str = ""
game_mode: str = "video"
duration_seconds: int = 0
distance_traveled: float = 0.0
score: int = 0
questions_answered: int = 0
questions_correct: int = 0
difficulty_level: int = 3
started_at: Optional[datetime] = None
ended_at: Optional[datetime] = None
metadata: Optional[Dict[str, Any]] = None
@dataclass
class GameQuizAnswer:
"""Individual quiz answer record."""
id: Optional[str] = None
session_id: Optional[str] = None
question_id: str = ""
subject: str = ""
difficulty: int = 3
is_correct: bool = False
answer_time_ms: int = 0
created_at: Optional[datetime] = None
@dataclass
class Achievement:
"""Achievement definition and unlock status."""
id: str
name: str
description: str
icon: str = "star"
category: str = "general" # general, streak, accuracy, time, score
threshold: int = 1
unlocked: bool = False
unlocked_at: Optional[datetime] = None
progress: int = 0
# Achievement definitions (static, not in DB)
ACHIEVEMENTS = [
# Erste Schritte
Achievement(id="first_game", name="Erste Fahrt", description="Spiele dein erstes Spiel", icon="rocket", category="general", threshold=1),
Achievement(id="five_games", name="Regelmaessiger Fahrer", description="Spiele 5 Spiele", icon="car", category="general", threshold=5),
Achievement(id="twenty_games", name="Erfahrener Pilot", description="Spiele 20 Spiele", icon="trophy", category="general", threshold=20),
# Serien
Achievement(id="streak_3", name="Guter Start", description="3 richtige Antworten hintereinander", icon="fire", category="streak", threshold=3),
Achievement(id="streak_5", name="Auf Feuer", description="5 richtige Antworten hintereinander", icon="fire", category="streak", threshold=5),
Achievement(id="streak_10", name="Unaufhaltsam", description="10 richtige Antworten hintereinander", icon="fire", category="streak", threshold=10),
# Genauigkeit
Achievement(id="perfect_game", name="Perfektes Spiel", description="100% richtig in einem Spiel (min. 5 Fragen)", icon="star", category="accuracy", threshold=100),
Achievement(id="accuracy_80", name="Scharfschuetze", description="80% Gesamtgenauigkeit (min. 50 Fragen)", icon="target", category="accuracy", threshold=80),
# Zeit
Achievement(id="play_30min", name="Ausdauer", description="30 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=30),
Achievement(id="play_60min", name="Marathon", description="60 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=60),
# Score
Achievement(id="score_5000", name="Punktejaeger", description="5.000 Punkte in einem Spiel", icon="gem", category="score", threshold=5000),
Achievement(id="score_10000", name="Highscore Hero", description="10.000 Punkte in einem Spiel", icon="crown", category="score", threshold=10000),
# Level
Achievement(id="level_up", name="Aufsteiger", description="Erreiche Level 2", icon="arrow-up", category="level", threshold=2),
Achievement(id="master", name="Meister", description="Erreiche Level 5", icon="medal", category="level", threshold=5),
]
+218
View File
@@ -0,0 +1,218 @@
# ==============================================
# Breakpilot Drive - Game Session & Quiz DB Methods
# ==============================================
# Methods for saving/querying game sessions, quiz answers, and basic leaderboard.
import json
import logging
from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__)
class SessionsMixin:
"""Mixin providing game session and quiz database methods for GameDatabase."""
async def save_game_session(
self,
student_id: str,
game_mode: str,
duration_seconds: int,
distance_traveled: float,
score: int,
questions_answered: int,
questions_correct: int,
difficulty_level: int,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""Save a game session and return the session ID."""
await self._ensure_connected()
if not self._pool:
return None
try:
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO game_sessions (
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8,
NOW() - make_interval(secs => $3), NOW(), $9)
RETURNING id
""",
student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
json.dumps(metadata) if metadata else None
)
if row:
return str(row["id"])
except Exception as e:
logger.error(f"Failed to save game session: {e}")
return None
async def get_user_sessions(
self,
student_id: str,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get recent game sessions for a user."""
await self._ensure_connected()
if not self._pool:
return []
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, student_id, game_mode, duration_seconds, distance_traveled,
score, questions_answered, questions_correct, difficulty_level,
started_at, ended_at, metadata
FROM game_sessions
WHERE student_id = $1
ORDER BY ended_at DESC
LIMIT $2
""",
student_id, limit
)
return [
{
"session_id": str(row["id"]),
"user_id": str(row["student_id"]),
"game_mode": row["game_mode"],
"duration_seconds": row["duration_seconds"],
"distance_traveled": float(row["distance_traveled"]) if row["distance_traveled"] else 0.0,
"score": row["score"],
"questions_answered": row["questions_answered"],
"questions_correct": row["questions_correct"],
"difficulty_level": row["difficulty_level"],
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get user sessions: {e}")
return []
async def get_leaderboard(
self,
timeframe: str = "day",
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get leaderboard data."""
await self._ensure_connected()
if not self._pool:
return []
# Timeframe filter
timeframe_sql = {
"day": "ended_at > NOW() - INTERVAL '1 day'",
"week": "ended_at > NOW() - INTERVAL '7 days'",
"month": "ended_at > NOW() - INTERVAL '30 days'",
"all": "1=1",
}.get(timeframe, "1=1")
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT student_id, SUM(score) as total_score
FROM game_sessions
WHERE {timeframe_sql}
GROUP BY student_id
ORDER BY total_score DESC
LIMIT $1
""",
limit
)
return [
{
"rank": i + 1,
"user_id": str(row["student_id"]),
"total_score": int(row["total_score"]),
}
for i, row in enumerate(rows)
]
except Exception as e:
logger.error(f"Failed to get leaderboard: {e}")
return []
async def save_quiz_answer(
self,
session_id: str,
question_id: str,
subject: str,
difficulty: int,
is_correct: bool,
answer_time_ms: int,
) -> bool:
"""Save an individual quiz answer."""
await self._ensure_connected()
if not self._pool:
return False
try:
async with self._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO game_quiz_answers (
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
) VALUES ($1, $2, $3, $4, $5, $6)
""",
session_id, question_id, subject, difficulty,
is_correct, answer_time_ms
)
return True
except Exception as e:
logger.error(f"Failed to save quiz answer: {e}")
return False
async def get_subject_stats(
self,
student_id: str
) -> Dict[str, Dict[str, Any]]:
"""Get per-subject statistics for a student."""
await self._ensure_connected()
if not self._pool:
return {}
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
qa.subject,
COUNT(*) as total,
SUM(CASE WHEN qa.is_correct THEN 1 ELSE 0 END) as correct,
AVG(qa.answer_time_ms) as avg_time_ms
FROM game_quiz_answers qa
JOIN game_sessions gs ON qa.session_id = gs.id
WHERE gs.student_id = $1
GROUP BY qa.subject
""",
student_id
)
return {
row["subject"]: {
"total": row["total"],
"correct": row["correct"],
"accuracy": row["correct"] / row["total"] if row["total"] > 0 else 0.0,
"avg_time_ms": int(row["avg_time_ms"]) if row["avg_time_ms"] else 0,
}
for row in rows
}
except Exception as e:
logger.error(f"Failed to get subject stats: {e}")
return {}
+290
View File
@@ -0,0 +1,290 @@
"""
Crop API endpoints (Step 4 / UI index 3 of OCR Pipeline).
Auto-crop, manual crop, and skip-crop for scanner/book borders.
"""
import logging
import time
from typing import Any, Dict
import cv2
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from page_crop import detect_and_crop_page, detect_page_splits
from ocr_pipeline_session_store import get_sub_sessions, update_session_db
from orientation_crop_helpers import ensure_cached, append_pipeline_log
from page_sub_sessions import create_page_sub_sessions
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
# ---------------------------------------------------------------------------
# Step 4 (UI index 3): Crop — runs after deskew + dewarp
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/crop")
async def auto_crop(session_id: str):
"""Auto-detect and crop scanner/book borders.
Reads the dewarped image (post-deskew + dewarp, so the page is straight).
Falls back to oriented -> original if earlier steps were skipped.
If the image is a multi-page spread (e.g. book on scanner), it will
automatically split into separate sub-sessions per page, crop each
individually, and return the split info.
"""
cached = await ensure_cached(session_id)
# Use dewarped (preferred), fall back to oriented, then original
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for cropping")
t0 = time.time()
# --- Check for existing sub-sessions (from page-split step) ---
# If page-split already created sub-sessions, skip multi-page detection
# in the crop step. Each sub-session runs its own crop independently.
existing_subs = await get_sub_sessions(session_id)
if existing_subs:
crop_result = cached.get("crop_result") or {}
if crop_result.get("multi_page"):
# Already split -- just return the existing info
duration = time.time() - t0
h, w = img_bgr.shape[:2]
return {
"session_id": session_id,
**crop_result,
"image_width": w,
"image_height": h,
"sub_sessions": [
{"id": s["id"], "name": s.get("name"), "page_index": s.get("box_index", i)}
for i, s in enumerate(existing_subs)
],
"note": "Page split was already performed; each sub-session runs its own crop.",
}
# --- Multi-page detection (fallback for sessions that skipped page-split) ---
page_splits = detect_page_splits(img_bgr)
if page_splits and len(page_splits) >= 2:
# Multi-page spread detected -- create sub-sessions
sub_sessions = await create_page_sub_sessions(
session_id, cached, img_bgr, page_splits,
)
duration = time.time() - t0
crop_info: Dict[str, Any] = {
"crop_applied": True,
"multi_page": True,
"page_count": len(page_splits),
"page_splits": page_splits,
"duration_seconds": round(duration, 2),
}
cached["crop_result"] = crop_info
# Store the first page as the main cropped image for backward compat
first_page = page_splits[0]
first_bgr = img_bgr[
first_page["y"]:first_page["y"] + first_page["height"],
first_page["x"]:first_page["x"] + first_page["width"],
].copy()
first_cropped, _ = detect_and_crop_page(first_bgr)
cached["cropped_bgr"] = first_cropped
ok, png_buf = cv2.imencode(".png", first_cropped)
await update_session_db(
session_id,
cropped_png=png_buf.tobytes() if ok else b"",
crop_result=crop_info,
current_step=5,
status='split',
)
logger.info(
"OCR Pipeline: crop session %s: multi-page split into %d pages in %.2fs",
session_id, len(page_splits), duration,
)
await append_pipeline_log(session_id, "crop", {
"multi_page": True,
"page_count": len(page_splits),
}, duration_ms=int(duration * 1000))
h, w = first_cropped.shape[:2]
return {
"session_id": session_id,
**crop_info,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
"sub_sessions": sub_sessions,
}
# --- Single page (normal) ---
cropped_bgr, crop_info = detect_and_crop_page(img_bgr)
duration = time.time() - t0
crop_info["duration_seconds"] = round(duration, 2)
crop_info["multi_page"] = False
# Encode cropped image
success, png_buf = cv2.imencode(".png", cropped_bgr)
cropped_png = png_buf.tobytes() if success else b""
# Update cache
cached["cropped_bgr"] = cropped_bgr
cached["crop_result"] = crop_info
# Persist to DB
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_info,
current_step=5,
)
logger.info(
"OCR Pipeline: crop session %s: applied=%s format=%s in %.2fs",
session_id, crop_info["crop_applied"],
crop_info.get("detected_format", "?"),
duration,
)
await append_pipeline_log(session_id, "crop", {
"crop_applied": crop_info["crop_applied"],
"detected_format": crop_info.get("detected_format"),
"format_confidence": crop_info.get("format_confidence"),
}, duration_ms=int(duration * 1000))
h, w = cropped_bgr.shape[:2]
return {
"session_id": session_id,
**crop_info,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
class ManualCropRequest(BaseModel):
x: float # percentage 0-100
y: float # percentage 0-100
width: float # percentage 0-100
height: float # percentage 0-100
@router.post("/sessions/{session_id}/crop/manual")
async def manual_crop(session_id: str, req: ManualCropRequest):
"""Manually crop using percentage coordinates."""
cached = await ensure_cached(session_id)
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for cropping")
h, w = img_bgr.shape[:2]
# Convert percentages to pixels
px_x = int(w * req.x / 100.0)
px_y = int(h * req.y / 100.0)
px_w = int(w * req.width / 100.0)
px_h = int(h * req.height / 100.0)
# Clamp
px_x = max(0, min(px_x, w - 1))
px_y = max(0, min(px_y, h - 1))
px_w = max(1, min(px_w, w - px_x))
px_h = max(1, min(px_h, h - px_y))
cropped_bgr = img_bgr[px_y:px_y + px_h, px_x:px_x + px_w].copy()
success, png_buf = cv2.imencode(".png", cropped_bgr)
cropped_png = png_buf.tobytes() if success else b""
crop_result = {
"crop_applied": True,
"crop_rect": {"x": px_x, "y": px_y, "width": px_w, "height": px_h},
"crop_rect_pct": {"x": round(req.x, 2), "y": round(req.y, 2),
"width": round(req.width, 2), "height": round(req.height, 2)},
"original_size": {"width": w, "height": h},
"cropped_size": {"width": px_w, "height": px_h},
"method": "manual",
}
cached["cropped_bgr"] = cropped_bgr
cached["crop_result"] = crop_result
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_result,
current_step=5,
)
ch, cw = cropped_bgr.shape[:2]
return {
"session_id": session_id,
**crop_result,
"image_width": cw,
"image_height": ch,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
@router.post("/sessions/{session_id}/crop/skip")
async def skip_crop(session_id: str):
"""Skip cropping -- use dewarped (or oriented/original) image as-is."""
cached = await ensure_cached(session_id)
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available")
h, w = img_bgr.shape[:2]
# Store the dewarped image as cropped (identity crop)
success, png_buf = cv2.imencode(".png", img_bgr)
cropped_png = png_buf.tobytes() if success else b""
crop_result = {
"crop_applied": False,
"skipped": True,
"original_size": {"width": w, "height": h},
"cropped_size": {"width": w, "height": h},
}
cached["cropped_bgr"] = img_bgr
cached["crop_result"] = crop_result
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_result,
current_step=5,
)
return {
"session_id": session_id,
**crop_result,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
+26 -650
View File
@@ -1,658 +1,34 @@
"""
Erwartungshorizont Templates for Vorabitur Mode
Erwartungshorizont Templates for Vorabitur Mode — barrel re-export.
Provides pre-defined templates based on German Abitur text analysis types:
- Textanalyse (pragmatische Texte)
- Sachtextanalyse
- Gedichtanalyse / Lyrikinterpretation
- Dramenanalyse
- Epische Textanalyse / Prosaanalyse
- Eroerterung (textgebunden / frei)
- Literarische Eroerterung
- Materialgestuetztes Schreiben
Each template includes:
- Structured criteria with weights
- Typical expectations per section
- NiBiS-aligned evaluation points
The actual code lives in:
- eh_templates_types.py (AUFGABENTYPEN, EHKriterium, EHTemplate)
- eh_templates_analyse.py (Textanalyse, Gedicht, Prosa, Drama)
- eh_templates_eroerterung.py (Eroerterung textgebunden)
- eh_templates_registry.py (TEMPLATES, get_template, list_templates, etc.)
"""
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
# =============================================
# TEMPLATE TYPES
# =============================================
AUFGABENTYPEN = {
"textanalyse_pragmatisch": {
"name": "Textanalyse (pragmatische Texte)",
"description": "Analyse von Sachtexten, Reden, Kommentaren, Essays",
"category": "analyse"
},
"sachtextanalyse": {
"name": "Sachtextanalyse",
"description": "Analyse von informativen und appellativen Sachtexten",
"category": "analyse"
},
"gedichtanalyse": {
"name": "Gedichtanalyse / Lyrikinterpretation",
"description": "Analyse und Interpretation lyrischer Texte",
"category": "interpretation"
},
"dramenanalyse": {
"name": "Dramenanalyse",
"description": "Analyse dramatischer Texte und Szenen",
"category": "interpretation"
},
"prosaanalyse": {
"name": "Epische Textanalyse / Prosaanalyse",
"description": "Analyse von Romanauszuegen, Kurzgeschichten, Novellen",
"category": "interpretation"
},
"eroerterung_textgebunden": {
"name": "Textgebundene Eroerterung",
"description": "Eroerterung auf Basis eines Sachtextes",
"category": "argumentation"
},
"eroerterung_frei": {
"name": "Freie Eroerterung",
"description": "Freie Eroerterung zu einem Thema",
"category": "argumentation"
},
"eroerterung_literarisch": {
"name": "Literarische Eroerterung",
"description": "Eroerterung zu literarischen Fragestellungen",
"category": "argumentation"
},
"materialgestuetzt": {
"name": "Materialgestuetztes Schreiben",
"description": "Verfassen eines Textes auf Materialbasis",
"category": "produktion"
}
}
# =============================================
# TEMPLATE STRUCTURES
# =============================================
@dataclass
class EHKriterium:
"""Single criterion in an Erwartungshorizont."""
id: str
name: str
beschreibung: str
gewichtung: int # Percentage weight (0-100)
erwartungen: List[str] # Expected points/elements
max_punkte: int = 100
def to_dict(self):
return asdict(self)
@dataclass
class EHTemplate:
"""Complete Erwartungshorizont template."""
id: str
aufgabentyp: str
name: str
beschreibung: str
kriterien: List[EHKriterium]
einleitung_hinweise: List[str]
hauptteil_hinweise: List[str]
schluss_hinweise: List[str]
sprachliche_aspekte: List[str]
created_at: datetime = field(default_factory=lambda: datetime.now())
def to_dict(self):
d = {
'id': self.id,
'aufgabentyp': self.aufgabentyp,
'name': self.name,
'beschreibung': self.beschreibung,
'kriterien': [k.to_dict() for k in self.kriterien],
'einleitung_hinweise': self.einleitung_hinweise,
'hauptteil_hinweise': self.hauptteil_hinweise,
'schluss_hinweise': self.schluss_hinweise,
'sprachliche_aspekte': self.sprachliche_aspekte,
'created_at': self.created_at.isoformat()
}
return d
# =============================================
# PRE-DEFINED TEMPLATES
# =============================================
def get_textanalyse_template() -> EHTemplate:
"""Template for pragmatic text analysis."""
return EHTemplate(
id="template_textanalyse_pragmatisch",
aufgabentyp="textanalyse_pragmatisch",
name="Textanalyse pragmatischer Texte",
beschreibung="Vorlage fuer die Analyse von Sachtexten, Reden, Kommentaren und Essays",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Wiedergabe des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Textaussage/These",
"Vollstaendige Wiedergabe der Argumentationsstruktur",
"Erkennen von Intention und Adressatenbezug",
"Einordnung in den historischen/gesellschaftlichen Kontext",
"Beruecksichtigung aller relevanten Textaspekte"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau und Gliederung der Analyse",
gewichtung=15,
erwartungen=[
"Sinnvolle Einleitung mit Basisinformationen",
"Logische Gliederung des Hauptteils",
"Stringente Gedankenfuehrung",
"Angemessener Schluss mit Fazit/Wertung",
"Absatzgliederung und Ueberlaenge"
]
),
EHKriterium(
id="analyse",
name="Analytische Qualitaet",
beschreibung="Tiefe und Qualitaet der Analyse",
gewichtung=15,
erwartungen=[
"Erkennen rhetorischer Mittel",
"Funktionale Deutung der Stilmittel",
"Analyse der Argumentationsweise",
"Beruecksichtigung von Wortwahl und Satzbau",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung",
"Korrekte Fremdwortschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung",
"Korrekte Bezuege und Kongruenz"
]
)
],
einleitung_hinweise=[
"Nennung von Autor, Titel, Textsorte, Erscheinungsjahr",
"Benennung des Themas",
"Formulierung der Kernthese/Hauptaussage",
"Ggf. Einordnung in den Kontext"
],
hauptteil_hinweise=[
"Systematische Analyse der Argumentationsstruktur",
"Untersuchung der sprachlichen Gestaltung",
"Funktionale Deutung der Stilmittel",
"Beruecksichtigung von Adressatenbezug und Intention",
"Textbelege durch Zitate"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bewertung der Ueberzeugungskraft",
"Ggf. aktuelle Relevanz",
"Persoenliche Stellungnahme (wenn gefordert)"
],
sprachliche_aspekte=[
"Fachsprachliche Begriffe korrekt verwenden",
"Konjunktiv fuer indirekte Rede",
"Praesens als Tempus der Analyse",
"Sachlicher, analytischer Stil"
]
# Types
from eh_templates_types import ( # noqa: F401
AUFGABENTYPEN,
EHKriterium,
EHTemplate,
)
def get_gedichtanalyse_template() -> EHTemplate:
"""Template for poetry analysis."""
return EHTemplate(
id="template_gedichtanalyse",
aufgabentyp="gedichtanalyse",
name="Gedichtanalyse / Lyrikinterpretation",
beschreibung="Vorlage fuer die Analyse und Interpretation lyrischer Texte",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Gedichtinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung des lyrischen Ichs und der Sprechsituation",
"Vollstaendige inhaltliche Erschliessung aller Strophen",
"Erkennen der zentralen Motive und Themen",
"Epochenzuordnung und literaturgeschichtliche Einordnung",
"Deutung der Bildlichkeit und Symbolik"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Interpretation",
gewichtung=15,
erwartungen=[
"Einleitung mit Basisinformationen",
"Systematische strophenweise oder aspektorientierte Analyse",
"Verknuepfung von Form- und Inhaltsanalyse",
"Schluessige Gesamtdeutung im Schluss"
]
),
EHKriterium(
id="formanalyse",
name="Formale Analyse",
beschreibung="Analyse der lyrischen Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung von Metrum und Reimschema",
"Analyse der Klanggestaltung",
"Erkennen von Enjambements und Zaesuren",
"Deutung der formalen Mittel",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung"
]
# Template factories
from eh_templates_analyse import ( # noqa: F401
get_textanalyse_template,
get_gedichtanalyse_template,
get_prosaanalyse_template,
get_dramenanalyse_template,
)
],
einleitung_hinweise=[
"Autor, Titel, Entstehungsjahr/Epoche",
"Thema/Motiv des Gedichts",
"Erste Deutungshypothese",
"Formale Grunddaten (Strophen, Verse)"
],
hauptteil_hinweise=[
"Inhaltliche Analyse (strophenweise oder aspektorientiert)",
"Formale Analyse (Metrum, Reim, Klang)",
"Sprachliche Analyse (Stilmittel, Bildlichkeit)",
"Funktionale Verknuepfung aller Ebenen",
"Textbelege durch Zitate mit Versangabe"
],
schluss_hinweise=[
"Zusammenfassung der Interpretationsergebnisse",
"Bestaetigung/Modifikation der Deutungshypothese",
"Einordnung in Epoche/Werk des Autors",
"Aktualitaetsbezug (wenn sinnvoll)"
],
sprachliche_aspekte=[
"Fachbegriffe der Lyrikanalyse verwenden",
"Zwischen lyrischem Ich und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende statt beschreibende Formulierungen"
]
from eh_templates_eroerterung import get_eroerterung_template # noqa: F401
# Registry
from eh_templates_registry import ( # noqa: F401
TEMPLATES,
initialize_templates,
get_template,
list_templates,
get_aufgabentypen,
)
def get_eroerterung_template() -> EHTemplate:
"""Template for textgebundene Eroerterung."""
return EHTemplate(
id="template_eroerterung_textgebunden",
aufgabentyp="eroerterung_textgebunden",
name="Textgebundene Eroerterung",
beschreibung="Vorlage fuer die textgebundene Eroerterung auf Basis eines Sachtextes",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Qualitaet der Argumentation",
gewichtung=40,
erwartungen=[
"Korrekte Wiedergabe der Textposition",
"Differenzierte eigene Argumentation",
"Vielfaeltige und ueberzeugende Argumente",
"Beruecksichtigung von Pro und Contra",
"Sinnvolle Beispiele und Belege",
"Eigenstaendige Schlussfolgerung"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Eroerterung",
gewichtung=15,
erwartungen=[
"Problemorientierte Einleitung",
"Klare Gliederung der Argumentation",
"Logische Argumentationsfolge",
"Sinnvolle Ueberlaetze",
"Begruendetes Fazit"
]
),
EHKriterium(
id="textbezug",
name="Textbezug",
beschreibung="Verknuepfung mit dem Ausgangstext",
gewichtung=15,
erwartungen=[
"Angemessene Textwiedergabe",
"Kritische Auseinandersetzung mit Textposition",
"Korrekte Zitierweise",
"Verknuepfung eigener Argumente mit Text"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung",
"Variationsreicher Ausdruck"
]
)
],
einleitung_hinweise=[
"Hinfuehrung zum Thema",
"Nennung des Ausgangstextes",
"Formulierung der Leitfrage/These",
"Ueberleitung zum Hauptteil"
],
hauptteil_hinweise=[
"Kurze Wiedergabe der Textposition",
"Systematische Argumentation (dialektisch oder linear)",
"Jedes Argument: These - Begruendung - Beispiel",
"Gewichtung der Argumente",
"Verknuepfung mit Textposition"
],
schluss_hinweise=[
"Zusammenfassung der wichtigsten Argumente",
"Eigene begruendete Stellungnahme",
"Ggf. Ausblick oder Appell"
],
sprachliche_aspekte=[
"Argumentative Konnektoren verwenden",
"Sachlicher, ueberzeugender Stil",
"Eigene Meinung kennzeichnen",
"Konjunktiv fuer Textpositionen"
]
)
def get_prosaanalyse_template() -> EHTemplate:
"""Template for prose/narrative text analysis."""
return EHTemplate(
id="template_prosaanalyse",
aufgabentyp="prosaanalyse",
name="Epische Textanalyse / Prosaanalyse",
beschreibung="Vorlage fuer die Analyse von Romanauszuegen, Kurzgeschichten und Novellen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Charakterisierung der Figuren",
"Erkennen der Erzaehlsituation",
"Deutung der Konflikte und Motive",
"Einordnung in den Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Informative Einleitung",
"Systematische Analyse im Hauptteil",
"Verknuepfung der Analyseergebnisse",
"Schluessige Gesamtdeutung"
]
),
EHKriterium(
id="erzaehltechnik",
name="Erzaehltechnische Analyse",
beschreibung="Analyse narrativer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung der Erzaehlperspektive",
"Analyse von Zeitgestaltung",
"Raumgestaltung und Atmosphaere",
"Figurenrede und Bewusstseinsdarstellung",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Textsorte, Erscheinungsjahr",
"Einordnung des Auszugs in den Gesamttext",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Kurze Inhaltsangabe des Auszugs",
"Analyse der Handlungsstruktur",
"Figurenanalyse mit Textbelegen",
"Erzaehltechnische Analyse",
"Sprachliche Analyse",
"Verknuepfung aller Ebenen"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bestaetigung der Deutungshypothese",
"Bedeutung fuer Gesamtwerk",
"Ggf. Aktualitaetsbezug"
],
sprachliche_aspekte=[
"Fachbegriffe der Erzaehltextanalyse",
"Zwischen Erzaehler und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende Formulierungen"
]
)
def get_dramenanalyse_template() -> EHTemplate:
"""Template for drama analysis."""
return EHTemplate(
id="template_dramenanalyse",
aufgabentyp="dramenanalyse",
name="Dramenanalyse",
beschreibung="Vorlage fuer die Analyse dramatischer Texte und Szenen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Szeneninhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Analyse der Figurenkonstellation",
"Erkennen des dramatischen Konflikts",
"Einordnung in den Handlungsverlauf",
"Deutung der Szene im Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Einleitung mit Kontextualisierung",
"Systematische Szenenanalyse",
"Verknuepfung der Analyseergebnisse",
"Schluessige Deutung"
]
),
EHKriterium(
id="dramentechnik",
name="Dramentechnische Analyse",
beschreibung="Analyse dramatischer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Analyse der Dialoggestaltung",
"Regieanweisungen und Buehnenraum",
"Dramatische Spannung",
"Monolog/Dialog-Formen",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Uraufführungsjahr, Dramenform",
"Einordnung der Szene in den Handlungsverlauf",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Situierung der Szene",
"Analyse des Dialogverlaufs",
"Figurenanalyse im Dialog",
"Sprachliche Analyse",
"Dramentechnische Mittel",
"Bedeutung fuer den Konflikt"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Funktion der Szene im Drama",
"Bedeutung fuer die Gesamtdeutung"
],
sprachliche_aspekte=[
"Fachbegriffe der Dramenanalyse",
"Praesens als Analysetempus",
"Korrekte Zitierweise mit Akt/Szene/Zeile"
]
)
# =============================================
# TEMPLATE REGISTRY
# =============================================
TEMPLATES: Dict[str, EHTemplate] = {}
def initialize_templates():
"""Initialize all pre-defined templates."""
global TEMPLATES
TEMPLATES = {
"textanalyse_pragmatisch": get_textanalyse_template(),
"gedichtanalyse": get_gedichtanalyse_template(),
"eroerterung_textgebunden": get_eroerterung_template(),
"prosaanalyse": get_prosaanalyse_template(),
"dramenanalyse": get_dramenanalyse_template(),
}
def get_template(aufgabentyp: str) -> Optional[EHTemplate]:
"""Get a template by Aufgabentyp."""
if not TEMPLATES:
initialize_templates()
return TEMPLATES.get(aufgabentyp)
def list_templates() -> List[Dict]:
"""List all available templates."""
if not TEMPLATES:
initialize_templates()
return [
{
"aufgabentyp": typ,
"name": AUFGABENTYPEN.get(typ, {}).get("name", typ),
"description": AUFGABENTYPEN.get(typ, {}).get("description", ""),
"category": AUFGABENTYPEN.get(typ, {}).get("category", "other"),
}
for typ in TEMPLATES.keys()
]
def get_aufgabentypen() -> Dict:
"""Get all Aufgabentypen definitions."""
return AUFGABENTYPEN
# Initialize on import
initialize_templates()
@@ -0,0 +1,395 @@
"""
Erwartungshorizont Templates — Analyse templates.
Contains templates for:
- Textanalyse (pragmatische Texte)
- Gedichtanalyse / Lyrikinterpretation
- Prosaanalyse
- Dramenanalyse
"""
from eh_templates_types import EHTemplate, EHKriterium
def get_textanalyse_template() -> EHTemplate:
"""Template for pragmatic text analysis."""
return EHTemplate(
id="template_textanalyse_pragmatisch",
aufgabentyp="textanalyse_pragmatisch",
name="Textanalyse pragmatischer Texte",
beschreibung="Vorlage fuer die Analyse von Sachtexten, Reden, Kommentaren und Essays",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Wiedergabe des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Textaussage/These",
"Vollstaendige Wiedergabe der Argumentationsstruktur",
"Erkennen von Intention und Adressatenbezug",
"Einordnung in den historischen/gesellschaftlichen Kontext",
"Beruecksichtigung aller relevanten Textaspekte"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau und Gliederung der Analyse",
gewichtung=15,
erwartungen=[
"Sinnvolle Einleitung mit Basisinformationen",
"Logische Gliederung des Hauptteils",
"Stringente Gedankenfuehrung",
"Angemessener Schluss mit Fazit/Wertung",
"Absatzgliederung und Ueberlaenge"
]
),
EHKriterium(
id="analyse",
name="Analytische Qualitaet",
beschreibung="Tiefe und Qualitaet der Analyse",
gewichtung=15,
erwartungen=[
"Erkennen rhetorischer Mittel",
"Funktionale Deutung der Stilmittel",
"Analyse der Argumentationsweise",
"Beruecksichtigung von Wortwahl und Satzbau",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung",
"Korrekte Fremdwortschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung",
"Korrekte Bezuege und Kongruenz"
]
)
],
einleitung_hinweise=[
"Nennung von Autor, Titel, Textsorte, Erscheinungsjahr",
"Benennung des Themas",
"Formulierung der Kernthese/Hauptaussage",
"Ggf. Einordnung in den Kontext"
],
hauptteil_hinweise=[
"Systematische Analyse der Argumentationsstruktur",
"Untersuchung der sprachlichen Gestaltung",
"Funktionale Deutung der Stilmittel",
"Beruecksichtigung von Adressatenbezug und Intention",
"Textbelege durch Zitate"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bewertung der Ueberzeugungskraft",
"Ggf. aktuelle Relevanz",
"Persoenliche Stellungnahme (wenn gefordert)"
],
sprachliche_aspekte=[
"Fachsprachliche Begriffe korrekt verwenden",
"Konjunktiv fuer indirekte Rede",
"Praesens als Tempus der Analyse",
"Sachlicher, analytischer Stil"
]
)
def get_gedichtanalyse_template() -> EHTemplate:
"""Template for poetry analysis."""
return EHTemplate(
id="template_gedichtanalyse",
aufgabentyp="gedichtanalyse",
name="Gedichtanalyse / Lyrikinterpretation",
beschreibung="Vorlage fuer die Analyse und Interpretation lyrischer Texte",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Gedichtinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung des lyrischen Ichs und der Sprechsituation",
"Vollstaendige inhaltliche Erschliessung aller Strophen",
"Erkennen der zentralen Motive und Themen",
"Epochenzuordnung und literaturgeschichtliche Einordnung",
"Deutung der Bildlichkeit und Symbolik"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Interpretation",
gewichtung=15,
erwartungen=[
"Einleitung mit Basisinformationen",
"Systematische strophenweise oder aspektorientierte Analyse",
"Verknuepfung von Form- und Inhaltsanalyse",
"Schluessige Gesamtdeutung im Schluss"
]
),
EHKriterium(
id="formanalyse",
name="Formale Analyse",
beschreibung="Analyse der lyrischen Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung von Metrum und Reimschema",
"Analyse der Klanggestaltung",
"Erkennen von Enjambements und Zaesuren",
"Deutung der formalen Mittel",
"Verknuepfung von Form und Inhalt"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung",
"Korrekte Getrennt- und Zusammenschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Flexion",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Entstehungsjahr/Epoche",
"Thema/Motiv des Gedichts",
"Erste Deutungshypothese",
"Formale Grunddaten (Strophen, Verse)"
],
hauptteil_hinweise=[
"Inhaltliche Analyse (strophenweise oder aspektorientiert)",
"Formale Analyse (Metrum, Reim, Klang)",
"Sprachliche Analyse (Stilmittel, Bildlichkeit)",
"Funktionale Verknuepfung aller Ebenen",
"Textbelege durch Zitate mit Versangabe"
],
schluss_hinweise=[
"Zusammenfassung der Interpretationsergebnisse",
"Bestaetigung/Modifikation der Deutungshypothese",
"Einordnung in Epoche/Werk des Autors",
"Aktualitaetsbezug (wenn sinnvoll)"
],
sprachliche_aspekte=[
"Fachbegriffe der Lyrikanalyse verwenden",
"Zwischen lyrischem Ich und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende statt beschreibende Formulierungen"
]
)
def get_prosaanalyse_template() -> EHTemplate:
"""Template for prose/narrative text analysis."""
return EHTemplate(
id="template_prosaanalyse",
aufgabentyp="prosaanalyse",
name="Epische Textanalyse / Prosaanalyse",
beschreibung="Vorlage fuer die Analyse von Romanauszuegen, Kurzgeschichten und Novellen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Textinhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Charakterisierung der Figuren",
"Erkennen der Erzaehlsituation",
"Deutung der Konflikte und Motive",
"Einordnung in den Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Informative Einleitung",
"Systematische Analyse im Hauptteil",
"Verknuepfung der Analyseergebnisse",
"Schluessige Gesamtdeutung"
]
),
EHKriterium(
id="erzaehltechnik",
name="Erzaehltechnische Analyse",
beschreibung="Analyse narrativer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Bestimmung der Erzaehlperspektive",
"Analyse von Zeitgestaltung",
"Raumgestaltung und Atmosphaere",
"Figurenrede und Bewusstseinsdarstellung",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Textsorte, Erscheinungsjahr",
"Einordnung des Auszugs in den Gesamttext",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Kurze Inhaltsangabe des Auszugs",
"Analyse der Handlungsstruktur",
"Figurenanalyse mit Textbelegen",
"Erzaehltechnische Analyse",
"Sprachliche Analyse",
"Verknuepfung aller Ebenen"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Bestaetigung der Deutungshypothese",
"Bedeutung fuer Gesamtwerk",
"Ggf. Aktualitaetsbezug"
],
sprachliche_aspekte=[
"Fachbegriffe der Erzaehltextanalyse",
"Zwischen Erzaehler und Autor unterscheiden",
"Praesens als Analysetempus",
"Deutende Formulierungen"
]
)
def get_dramenanalyse_template() -> EHTemplate:
"""Template for drama analysis."""
return EHTemplate(
id="template_dramenanalyse",
aufgabentyp="dramenanalyse",
name="Dramenanalyse",
beschreibung="Vorlage fuer die Analyse dramatischer Texte und Szenen",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Erfassung und Deutung des Szeneninhalts",
gewichtung=40,
erwartungen=[
"Korrekte Erfassung der Handlung",
"Analyse der Figurenkonstellation",
"Erkennen des dramatischen Konflikts",
"Einordnung in den Handlungsverlauf",
"Deutung der Szene im Gesamtzusammenhang"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Analyse",
gewichtung=15,
erwartungen=[
"Einleitung mit Kontextualisierung",
"Systematische Szenenanalyse",
"Verknuepfung der Analyseergebnisse",
"Schluessige Deutung"
]
),
EHKriterium(
id="dramentechnik",
name="Dramentechnische Analyse",
beschreibung="Analyse dramatischer Gestaltungsmittel",
gewichtung=15,
erwartungen=[
"Analyse der Dialoggestaltung",
"Regieanweisungen und Buehnenraum",
"Dramatische Spannung",
"Monolog/Dialog-Formen",
"Funktionale Deutung"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung"
]
)
],
einleitung_hinweise=[
"Autor, Titel, Urauffuehrungsjahr, Dramenform",
"Einordnung der Szene in den Handlungsverlauf",
"Thema und Deutungshypothese"
],
hauptteil_hinweise=[
"Situierung der Szene",
"Analyse des Dialogverlaufs",
"Figurenanalyse im Dialog",
"Sprachliche Analyse",
"Dramentechnische Mittel",
"Bedeutung fuer den Konflikt"
],
schluss_hinweise=[
"Zusammenfassung der Analyseergebnisse",
"Funktion der Szene im Drama",
"Bedeutung fuer die Gesamtdeutung"
],
sprachliche_aspekte=[
"Fachbegriffe der Dramenanalyse",
"Praesens als Analysetempus",
"Korrekte Zitierweise mit Akt/Szene/Zeile"
]
)
@@ -0,0 +1,101 @@
"""
Erwartungshorizont Templates — Eroerterung template.
"""
from eh_templates_types import EHTemplate, EHKriterium
def get_eroerterung_template() -> EHTemplate:
"""Template for textgebundene Eroerterung."""
return EHTemplate(
id="template_eroerterung_textgebunden",
aufgabentyp="eroerterung_textgebunden",
name="Textgebundene Eroerterung",
beschreibung="Vorlage fuer die textgebundene Eroerterung auf Basis eines Sachtextes",
kriterien=[
EHKriterium(
id="inhalt",
name="Inhaltliche Leistung",
beschreibung="Qualitaet der Argumentation",
gewichtung=40,
erwartungen=[
"Korrekte Wiedergabe der Textposition",
"Differenzierte eigene Argumentation",
"Vielfaeltige und ueberzeugende Argumente",
"Beruecksichtigung von Pro und Contra",
"Sinnvolle Beispiele und Belege",
"Eigenstaendige Schlussfolgerung"
]
),
EHKriterium(
id="struktur",
name="Aufbau und Struktur",
beschreibung="Logischer Aufbau der Eroerterung",
gewichtung=15,
erwartungen=[
"Problemorientierte Einleitung",
"Klare Gliederung der Argumentation",
"Logische Argumentationsfolge",
"Sinnvolle Ueberlaetze",
"Begruendetes Fazit"
]
),
EHKriterium(
id="textbezug",
name="Textbezug",
beschreibung="Verknuepfung mit dem Ausgangstext",
gewichtung=15,
erwartungen=[
"Angemessene Textwiedergabe",
"Kritische Auseinandersetzung mit Textposition",
"Korrekte Zitierweise",
"Verknuepfung eigener Argumente mit Text"
]
),
EHKriterium(
id="rechtschreibung",
name="Sprachliche Richtigkeit (Rechtschreibung)",
beschreibung="Orthografische Korrektheit",
gewichtung=15,
erwartungen=[
"Korrekte Rechtschreibung",
"Korrekte Gross- und Kleinschreibung"
]
),
EHKriterium(
id="grammatik",
name="Sprachliche Richtigkeit (Grammatik)",
beschreibung="Grammatische Korrektheit und Zeichensetzung",
gewichtung=15,
erwartungen=[
"Korrekter Satzbau",
"Korrekte Zeichensetzung",
"Variationsreicher Ausdruck"
]
)
],
einleitung_hinweise=[
"Hinfuehrung zum Thema",
"Nennung des Ausgangstextes",
"Formulierung der Leitfrage/These",
"Ueberleitung zum Hauptteil"
],
hauptteil_hinweise=[
"Kurze Wiedergabe der Textposition",
"Systematische Argumentation (dialektisch oder linear)",
"Jedes Argument: These - Begruendung - Beispiel",
"Gewichtung der Argumente",
"Verknuepfung mit Textposition"
],
schluss_hinweise=[
"Zusammenfassung der wichtigsten Argumente",
"Eigene begruendete Stellungnahme",
"Ggf. Ausblick oder Appell"
],
sprachliche_aspekte=[
"Argumentative Konnektoren verwenden",
"Sachlicher, ueberzeugender Stil",
"Eigene Meinung kennzeichnen",
"Konjunktiv fuer Textpositionen"
]
)
@@ -0,0 +1,60 @@
"""
Erwartungshorizont Templates — registry for template lookup.
"""
from typing import Dict, List, Optional
from eh_templates_types import EHTemplate, AUFGABENTYPEN
from eh_templates_analyse import (
get_textanalyse_template,
get_gedichtanalyse_template,
get_prosaanalyse_template,
get_dramenanalyse_template,
)
from eh_templates_eroerterung import get_eroerterung_template
TEMPLATES: Dict[str, EHTemplate] = {}
def initialize_templates():
"""Initialize all pre-defined templates."""
global TEMPLATES
TEMPLATES = {
"textanalyse_pragmatisch": get_textanalyse_template(),
"gedichtanalyse": get_gedichtanalyse_template(),
"eroerterung_textgebunden": get_eroerterung_template(),
"prosaanalyse": get_prosaanalyse_template(),
"dramenanalyse": get_dramenanalyse_template(),
}
def get_template(aufgabentyp: str) -> Optional[EHTemplate]:
"""Get a template by Aufgabentyp."""
if not TEMPLATES:
initialize_templates()
return TEMPLATES.get(aufgabentyp)
def list_templates() -> List[Dict]:
"""List all available templates."""
if not TEMPLATES:
initialize_templates()
return [
{
"aufgabentyp": typ,
"name": AUFGABENTYPEN.get(typ, {}).get("name", typ),
"description": AUFGABENTYPEN.get(typ, {}).get("description", ""),
"category": AUFGABENTYPEN.get(typ, {}).get("category", "other"),
}
for typ in TEMPLATES.keys()
]
def get_aufgabentypen() -> Dict:
"""Get all Aufgabentypen definitions."""
return AUFGABENTYPEN
# Initialize on import
initialize_templates()
@@ -0,0 +1,100 @@
"""
Erwartungshorizont Templates — types and Aufgabentypen registry.
"""
from typing import Dict, List, Optional
from dataclasses import dataclass, field, asdict
from datetime import datetime
AUFGABENTYPEN = {
"textanalyse_pragmatisch": {
"name": "Textanalyse (pragmatische Texte)",
"description": "Analyse von Sachtexten, Reden, Kommentaren, Essays",
"category": "analyse"
},
"sachtextanalyse": {
"name": "Sachtextanalyse",
"description": "Analyse von informativen und appellativen Sachtexten",
"category": "analyse"
},
"gedichtanalyse": {
"name": "Gedichtanalyse / Lyrikinterpretation",
"description": "Analyse und Interpretation lyrischer Texte",
"category": "interpretation"
},
"dramenanalyse": {
"name": "Dramenanalyse",
"description": "Analyse dramatischer Texte und Szenen",
"category": "interpretation"
},
"prosaanalyse": {
"name": "Epische Textanalyse / Prosaanalyse",
"description": "Analyse von Romanauszuegen, Kurzgeschichten, Novellen",
"category": "interpretation"
},
"eroerterung_textgebunden": {
"name": "Textgebundene Eroerterung",
"description": "Eroerterung auf Basis eines Sachtextes",
"category": "argumentation"
},
"eroerterung_frei": {
"name": "Freie Eroerterung",
"description": "Freie Eroerterung zu einem Thema",
"category": "argumentation"
},
"eroerterung_literarisch": {
"name": "Literarische Eroerterung",
"description": "Eroerterung zu literarischen Fragestellungen",
"category": "argumentation"
},
"materialgestuetzt": {
"name": "Materialgestuetztes Schreiben",
"description": "Verfassen eines Textes auf Materialbasis",
"category": "produktion"
}
}
@dataclass
class EHKriterium:
"""Single criterion in an Erwartungshorizont."""
id: str
name: str
beschreibung: str
gewichtung: int # Percentage weight (0-100)
erwartungen: List[str] # Expected points/elements
max_punkte: int = 100
def to_dict(self):
return asdict(self)
@dataclass
class EHTemplate:
"""Complete Erwartungshorizont template."""
id: str
aufgabentyp: str
name: str
beschreibung: str
kriterien: List[EHKriterium]
einleitung_hinweise: List[str]
hauptteil_hinweise: List[str]
schluss_hinweise: List[str]
sprachliche_aspekte: List[str]
created_at: datetime = field(default_factory=lambda: datetime.now())
def to_dict(self):
d = {
'id': self.id,
'aufgabentyp': self.aufgabentyp,
'name': self.name,
'beschreibung': self.beschreibung,
'kriterien': [k.to_dict() for k in self.kriterien],
'einleitung_hinweise': self.einleitung_hinweise,
'hauptteil_hinweise': self.hauptteil_hinweise,
'schluss_hinweise': self.schluss_hinweise,
'sprachliche_aspekte': self.sprachliche_aspekte,
'created_at': self.created_at.isoformat()
}
return d
+27 -667
View File
@@ -1,671 +1,31 @@
"""
Grid Editor API — endpoints for grid building, editing, and export.
Grid Editor API — barrel re-export.
The core grid building logic is in grid_build_core.py.
The actual endpoints live in:
- grid_editor_api_grid.py (build-grid, rerun-ocr, save-grid, get-grid)
- grid_editor_api_gutter.py (gutter-repair, gutter-repair/apply)
- grid_editor_api_box.py (build-box-grids)
- grid_editor_api_unified.py (build-unified-grid, unified-grid)
This module re-exports the combined router and key symbols so that
existing `from grid_editor_api import router` / `from grid_editor_api import _build_grid_core`
continue to work unchanged.
"""
import logging
import re
import time
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, HTTPException, Query, Request
from grid_build_core import _build_grid_core
from grid_editor_helpers import _words_in_zone
from ocr_pipeline_session_store import (
get_session_db,
update_session_db,
)
from ocr_pipeline_common import (
_cache,
_load_session_to_cache,
_get_cached,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["grid-editor"])
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/build-grid")
async def build_grid(
session_id: str,
ipa_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
syllable_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
enhance: bool = Query(True, description="Step 3: CLAHE + denoise for degraded scans"),
max_cols: int = Query(0, description="Step 2: Max column count (0=unlimited)"),
min_conf: int = Query(0, description="Step 1: Min OCR confidence (0=auto)"),
):
"""Build a structured, zone-aware grid from existing Kombi word results.
Requires that paddle-kombi or rapid-kombi has already been run on the session.
Uses the image for box detection and the word positions for grid structuring.
Query params:
ipa_mode: "auto" (only when English IPA detected), "all" (force), "none" (skip)
syllable_mode: "auto" (only when original has dividers), "all" (force), "none" (skip)
Returns a StructuredGrid with zones, each containing their own
columns, rows, and cells — ready for the frontend Excel-like editor.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
try:
result = await _build_grid_core(
session_id, session,
ipa_mode=ipa_mode, syllable_mode=syllable_mode,
enhance=enhance,
max_columns=max_cols if max_cols > 0 else None,
min_conf=min_conf if min_conf > 0 else None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Save automatic grid snapshot for later comparison with manual corrections
# Lazy import to avoid circular dependency with ocr_pipeline_regression
from ocr_pipeline_regression import _build_reference_snapshot
wr = session.get("word_result") or {}
engine = wr.get("ocr_engine", "")
if engine in ("kombi", "rapid_kombi"):
auto_pipeline = "kombi"
elif engine == "paddle_direct":
auto_pipeline = "paddle-direct"
else:
auto_pipeline = "pipeline"
auto_snapshot = _build_reference_snapshot(result, pipeline=auto_pipeline)
gt = session.get("ground_truth") or {}
gt["auto_grid_snapshot"] = auto_snapshot
# Persist to DB and advance current_step to 11 (reconstruction complete)
await update_session_db(session_id, grid_editor_result=result, ground_truth=gt, current_step=11)
logger.info(
"build-grid session %s: %d zones, %d cols, %d rows, %d cells, "
"%d boxes in %.2fs",
session_id,
len(result.get("zones", [])),
result.get("summary", {}).get("total_columns", 0),
result.get("summary", {}).get("total_rows", 0),
result.get("summary", {}).get("total_cells", 0),
result.get("boxes_detected", 0),
result.get("duration_seconds", 0),
)
return result
@router.post("/sessions/{session_id}/rerun-ocr-and-build-grid")
async def rerun_ocr_and_build_grid(
session_id: str,
ipa_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
syllable_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
enhance: bool = Query(True, description="Step 3: CLAHE + denoise for degraded scans"),
max_cols: int = Query(0, description="Step 2: Max column count (0=unlimited)"),
min_conf: int = Query(0, description="Step 1: Min OCR confidence (0=auto)"),
vision_fusion: bool = Query(False, description="Step 4: Vision-LLM fusion for degraded scans"),
doc_category: str = Query("", description="Document type for Vision-LLM prompt context"),
):
"""Re-run OCR with quality settings, then rebuild the grid.
Unlike build-grid (which only rebuilds from existing words),
this endpoint re-runs the full OCR pipeline on the cropped image
with optional CLAHE enhancement, then builds the grid.
Steps executed: Image Enhancement → OCR → Grid Build
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
import time as _time
t0 = _time.time()
# 1. Load the cropped/dewarped image from cache or session
if session_id not in _cache:
await _load_session_to_cache(session_id)
cached = _get_cached(session_id)
dewarped_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr")
if dewarped_bgr is None:
raise HTTPException(status_code=400, detail="No cropped/dewarped image available. Run preprocessing steps first.")
import numpy as np
img_h, img_w = dewarped_bgr.shape[:2]
ocr_input = dewarped_bgr.copy()
# 2. Scan quality assessment
scan_quality_info = {}
try:
from scan_quality import score_scan_quality
quality_report = score_scan_quality(ocr_input)
scan_quality_info = quality_report.to_dict()
actual_min_conf = min_conf if min_conf > 0 else quality_report.recommended_min_conf
except Exception as e:
logger.warning(f"rerun-ocr: scan quality failed: {e}")
actual_min_conf = min_conf if min_conf > 0 else 40
# 3. Image enhancement (Step 3)
is_degraded = scan_quality_info.get("is_degraded", False)
if enhance and is_degraded:
try:
from ocr_image_enhance import enhance_for_ocr
ocr_input = enhance_for_ocr(ocr_input, is_degraded=True)
logger.info("rerun-ocr: CLAHE enhancement applied")
except Exception as e:
logger.warning(f"rerun-ocr: enhancement failed: {e}")
# 4. Run dual-engine OCR
from PIL import Image
import pytesseract
# RapidOCR
rapid_words = []
try:
from cv_ocr_engines import ocr_region_rapid
from cv_vocab_types import PageRegion
full_region = PageRegion(type="full_page", x=0, y=0, width=img_w, height=img_h)
rapid_words = ocr_region_rapid(ocr_input, full_region) or []
except Exception as e:
logger.warning(f"rerun-ocr: RapidOCR failed: {e}")
# Tesseract
pil_img = Image.fromarray(ocr_input[:, :, ::-1])
data = pytesseract.image_to_data(pil_img, lang='eng+deu', config='--psm 6 --oem 3', output_type=pytesseract.Output.DICT)
tess_words = []
for i in range(len(data["text"])):
text = (data["text"][i] or "").strip()
conf_raw = str(data["conf"][i])
conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1
if not text or conf < actual_min_conf:
continue
tess_words.append({
"text": text, "left": data["left"][i], "top": data["top"][i],
"width": data["width"][i], "height": data["height"][i], "conf": conf,
})
# 5. Merge OCR results
from ocr_pipeline_ocr_merge import _split_paddle_multi_words, _merge_paddle_tesseract, _deduplicate_words
rapid_split = _split_paddle_multi_words(rapid_words) if rapid_words else []
if rapid_split or tess_words:
merged_words = _merge_paddle_tesseract(rapid_split, tess_words)
merged_words = _deduplicate_words(merged_words)
else:
merged_words = tess_words
# 6. Store updated word_result in session
cells_for_storage = [{"text": w["text"], "left": w["left"], "top": w["top"],
"width": w["width"], "height": w["height"], "conf": w.get("conf", 0)}
for w in merged_words]
word_result = {
"cells": [{"text": " ".join(w["text"] for w in merged_words),
"word_boxes": cells_for_storage}],
"image_width": img_w,
"image_height": img_h,
"ocr_engine": "rapid_kombi",
"word_count": len(merged_words),
"raw_paddle_words": rapid_words,
}
# 6b. Vision-LLM Fusion (Step 4) — correct OCR using Vision model
vision_applied = False
if vision_fusion:
try:
from vision_ocr_fusion import vision_fuse_ocr
category = doc_category or session.get("document_category") or "vokabelseite"
logger.info(f"rerun-ocr: running Vision-LLM fusion (category={category})")
merged_words = await vision_fuse_ocr(ocr_input, merged_words, category)
vision_applied = True
# Rebuild storage from fused words
cells_for_storage = [{"text": w["text"], "left": w["left"], "top": w["top"],
"width": w["width"], "height": w["height"], "conf": w.get("conf", 0)}
for w in merged_words]
word_result["cells"] = [{"text": " ".join(w["text"] for w in merged_words),
"word_boxes": cells_for_storage}]
word_result["word_count"] = len(merged_words)
word_result["ocr_engine"] = "vision_fusion"
except Exception as e:
logger.warning(f"rerun-ocr: Vision-LLM fusion failed: {e}")
await update_session_db(session_id, word_result=word_result)
# Reload session with updated word_result
session = await get_session_db(session_id)
ocr_duration = _time.time() - t0
logger.info(
"rerun-ocr session %s: %d words (rapid=%d, tess=%d, merged=%d) in %.1fs "
"(enhance=%s, min_conf=%d, quality=%s)",
session_id, len(merged_words), len(rapid_words), len(tess_words),
len(merged_words), ocr_duration, enhance, actual_min_conf,
scan_quality_info.get("quality_pct", "?"),
)
# 7. Build grid from new words
try:
result = await _build_grid_core(
session_id, session,
ipa_mode=ipa_mode, syllable_mode=syllable_mode,
enhance=enhance,
max_columns=max_cols if max_cols > 0 else None,
min_conf=min_conf if min_conf > 0 else None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Persist grid
await update_session_db(session_id, grid_editor_result=result, current_step=11)
# Add quality info to response
result["scan_quality"] = scan_quality_info
result["ocr_stats"] = {
"rapid_words": len(rapid_words),
"tess_words": len(tess_words),
"merged_words": len(merged_words),
"min_conf_used": actual_min_conf,
"enhance_applied": enhance and is_degraded,
"vision_fusion_applied": vision_applied,
"document_category": doc_category or session.get("document_category", ""),
"ocr_duration_seconds": round(ocr_duration, 1),
}
total_duration = _time.time() - t0
logger.info(
"rerun-ocr+build-grid session %s: %d zones, %d cols, %d cells in %.1fs",
session_id,
len(result.get("zones", [])),
result.get("summary", {}).get("total_columns", 0),
result.get("summary", {}).get("total_cells", 0),
total_duration,
)
return result
@router.post("/sessions/{session_id}/save-grid")
async def save_grid(session_id: str, request: Request):
"""Save edited grid data from the frontend Excel-like editor.
Receives the full StructuredGrid with user edits (text changes,
formatting changes like bold columns, header rows, etc.) and
persists it to the session's grid_editor_result.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
body = await request.json()
# Validate basic structure
if "zones" not in body:
raise HTTPException(status_code=400, detail="Missing 'zones' in request body")
# Preserve metadata from the original build
existing = session.get("grid_editor_result") or {}
result = {
"session_id": session_id,
"image_width": body.get("image_width", existing.get("image_width", 0)),
"image_height": body.get("image_height", existing.get("image_height", 0)),
"zones": body["zones"],
"boxes_detected": body.get("boxes_detected", existing.get("boxes_detected", 0)),
"summary": body.get("summary", existing.get("summary", {})),
"formatting": body.get("formatting", existing.get("formatting", {})),
"duration_seconds": existing.get("duration_seconds", 0),
"edited": True,
}
await update_session_db(session_id, grid_editor_result=result, current_step=11)
logger.info("save-grid session %s: %d zones saved", session_id, len(body["zones"]))
return {"session_id": session_id, "saved": True}
@router.get("/sessions/{session_id}/grid-editor")
async def get_grid(session_id: str):
"""Retrieve the current grid editor state for a session."""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
result = session.get("grid_editor_result")
if not result:
raise HTTPException(
status_code=404,
detail="No grid editor data. Run build-grid first.",
)
return result
# ---------------------------------------------------------------------------
# Gutter Repair endpoints
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/gutter-repair")
async def gutter_repair(session_id: str):
"""Analyse grid for gutter-edge OCR errors and return repair suggestions.
Detects:
- Words truncated/blurred at the book binding (spell_fix)
- Words split across rows with missing hyphen chars (hyphen_join)
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(
status_code=400,
detail="No grid data. Run build-grid first.",
)
from cv_gutter_repair import analyse_grid_for_gutter_repair
image_width = grid_data.get("image_width", 0)
result = analyse_grid_for_gutter_repair(grid_data, image_width=image_width)
# Persist suggestions in ground_truth.gutter_repair (avoids DB migration)
gt = session.get("ground_truth") or {}
gt["gutter_repair"] = result
await update_session_db(session_id, ground_truth=gt)
logger.info(
"gutter-repair session %s: %d suggestions in %.2fs",
session_id,
result.get("stats", {}).get("suggestions_found", 0),
result.get("duration_seconds", 0),
)
return result
@router.post("/sessions/{session_id}/gutter-repair/apply")
async def gutter_repair_apply(session_id: str, request: Request):
"""Apply accepted gutter repair suggestions to the grid.
Body: { "accepted": ["suggestion_id_1", "suggestion_id_2", ...] }
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data.")
gt = session.get("ground_truth") or {}
gutter_result = gt.get("gutter_repair")
if not gutter_result:
raise HTTPException(
status_code=400,
detail="No gutter repair data. Run gutter-repair first.",
)
body = await request.json()
accepted_ids = body.get("accepted", [])
if not accepted_ids:
return {"applied_count": 0, "changes": []}
# text_overrides: { suggestion_id: "alternative_text" }
# Allows the user to pick a different correction from the alternatives list
text_overrides = body.get("text_overrides", {})
from cv_gutter_repair import apply_gutter_suggestions
suggestions = gutter_result.get("suggestions", [])
# Apply user-selected alternatives before passing to apply
for s in suggestions:
sid = s.get("id", "")
if sid in text_overrides and text_overrides[sid]:
s["suggested_text"] = text_overrides[sid]
result = apply_gutter_suggestions(grid_data, accepted_ids, suggestions)
# Save updated grid back to session
await update_session_db(session_id, grid_editor_result=grid_data)
logger.info(
"gutter-repair/apply session %s: %d changes applied",
session_id,
result.get("applied_count", 0),
)
return result
# ---------------------------------------------------------------------------
# Box-Grid-Review endpoints
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/build-box-grids")
async def build_box_grids(session_id: str, request: Request):
"""Rebuild grid structure for all detected boxes with layout-aware detection.
Uses structure_result.boxes (from Step 7) as the source of box coordinates,
and raw_paddle_words as OCR word source. Creates or updates box zones in
the grid_editor_result.
Optional body: { "overrides": { "0": "bullet_list" } }
Maps box_index → forced layout_type.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
# Get raw OCR words (with top/left/width/height keys)
word_result = session.get("word_result") or {}
all_words = word_result.get("raw_paddle_words") or word_result.get("raw_tesseract_words") or []
if not all_words:
raise HTTPException(status_code=400, detail="No raw OCR words available.")
# Get detected boxes from structure_result
structure_result = session.get("structure_result") or {}
gt = session.get("ground_truth") or {}
if not structure_result:
structure_result = gt.get("structure_result") or {}
detected_boxes = structure_result.get("boxes") or []
if not detected_boxes:
return {"session_id": session_id, "box_zones_rebuilt": 0, "spell_fixes": 0, "message": "No boxes detected"}
# Filter out false-positive boxes in header/footer margins.
# Textbook pages have ~2.5cm margins at top/bottom. At typical scan
# resolutions (150-300 DPI), that's roughly 5-10% of image height.
# A box whose vertical CENTER falls within the top or bottom 7% of
# the image is likely a page number, unit header, or running footer.
img_h_for_filter = grid_data.get("image_height", 0) or word_result.get("image_height", 0)
if img_h_for_filter > 0:
margin_frac = 0.07 # 7% of image height
margin_top = img_h_for_filter * margin_frac
margin_bottom = img_h_for_filter * (1 - margin_frac)
filtered = []
for box in detected_boxes:
by = box.get("y", 0)
bh = box.get("h", 0)
box_center_y = by + bh / 2
if box_center_y < margin_top or box_center_y > margin_bottom:
logger.info("build-box-grids: skipping header/footer box at y=%d h=%d (center=%.0f, margins=%.0f/%.0f)",
by, bh, box_center_y, margin_top, margin_bottom)
continue
filtered.append(box)
detected_boxes = filtered
body = {}
try:
body = await request.json()
except Exception:
pass
layout_overrides = body.get("overrides", {})
from cv_box_layout import build_box_zone_grid
from grid_editor_helpers import _words_in_zone
img_w = grid_data.get("image_width", 0) or word_result.get("image_width", 0)
img_h = grid_data.get("image_height", 0) or word_result.get("image_height", 0)
zones = grid_data.get("zones", [])
# Find highest existing zone_index
max_zone_idx = max((z.get("zone_index", 0) for z in zones), default=-1)
# Remove old box zones (we'll rebuild them)
zones = [z for z in zones if z.get("zone_type") != "box"]
box_count = 0
spell_fixes = 0
for box_idx, box in enumerate(detected_boxes):
bx = box.get("x", 0)
by = box.get("y", 0)
bw = box.get("w", 0)
bh = box.get("h", 0)
if bw <= 0 or bh <= 0:
continue
# Filter raw OCR words inside this box
zone_words = _words_in_zone(all_words, by, bh, bx, bw)
if not zone_words:
logger.info("Box %d: no words found in bbox (%d,%d,%d,%d)", box_idx, bx, by, bw, bh)
continue
zone_idx = max_zone_idx + 1 + box_idx
forced_layout = layout_overrides.get(str(box_idx))
# Build box grid
box_grid = build_box_zone_grid(
zone_words, bx, by, bw, bh,
zone_idx, img_w, img_h,
layout_type=forced_layout,
)
# Apply SmartSpellChecker to all box cells
try:
from smart_spell import SmartSpellChecker
ssc = SmartSpellChecker()
for cell in box_grid.get("cells", []):
text = cell.get("text", "")
if not text:
continue
result = ssc.correct_text(text, lang="auto")
if result.changed:
cell["text"] = result.corrected
spell_fixes += 1
except ImportError:
pass
# Build zone entry
zone_entry = {
"zone_index": zone_idx,
"zone_type": "box",
"bbox_px": {"x": bx, "y": by, "w": bw, "h": bh},
"bbox_pct": {
"x": round(bx / img_w * 100, 2) if img_w else 0,
"y": round(by / img_h * 100, 2) if img_h else 0,
"w": round(bw / img_w * 100, 2) if img_w else 0,
"h": round(bh / img_h * 100, 2) if img_h else 0,
},
"border": None,
"word_count": len(zone_words),
"columns": box_grid["columns"],
"rows": box_grid["rows"],
"cells": box_grid["cells"],
"header_rows": box_grid.get("header_rows", []),
"box_layout_type": box_grid.get("box_layout_type", "flowing"),
"box_grid_reviewed": False,
"box_bg_color": box.get("bg_color_name", ""),
"box_bg_hex": box.get("bg_color_hex", ""),
}
zones.append(zone_entry)
box_count += 1
# Sort zones by y-position for correct reading order
zones.sort(key=lambda z: z.get("bbox_px", {}).get("y", 0))
grid_data["zones"] = zones
await update_session_db(session_id, grid_editor_result=grid_data)
logger.info(
"build-box-grids session %s: %d boxes processed (%d words spell-fixed) from %d detected",
session_id, box_count, spell_fixes, len(detected_boxes),
)
return {
"session_id": session_id,
"box_zones_rebuilt": box_count,
"total_detected_boxes": len(detected_boxes),
"spell_fixes": spell_fixes,
"zones": zones,
}
# ---------------------------------------------------------------------------
# Unified Grid endpoint
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/build-unified-grid")
async def build_unified_grid_endpoint(session_id: str):
"""Build a single-zone unified grid merging content + box zones.
Takes the existing multi-zone grid_editor_result and produces a
unified grid where boxes are integrated into the main row sequence.
Persists as unified_grid_result (preserves original multi-zone data).
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
from unified_grid import build_unified_grid
result = build_unified_grid(
zones=grid_data.get("zones", []),
image_width=grid_data.get("image_width", 0),
image_height=grid_data.get("image_height", 0),
layout_metrics=grid_data.get("layout_metrics", {}),
)
# Persist as separate field (don't overwrite original multi-zone grid)
await update_session_db(session_id, unified_grid_result=result)
logger.info(
"build-unified-grid session %s: %d rows, %d cells",
session_id,
result.get("summary", {}).get("total_rows", 0),
result.get("summary", {}).get("total_cells", 0),
)
return result
@router.get("/sessions/{session_id}/unified-grid")
async def get_unified_grid(session_id: str):
"""Retrieve the unified grid for a session."""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
result = session.get("unified_grid_result")
if not result:
raise HTTPException(
status_code=404,
detail="No unified grid. Run build-unified-grid first.",
)
return result
from fastapi import APIRouter
from grid_editor_api_grid import router as _grid_router
from grid_editor_api_gutter import router as _gutter_router
from grid_editor_api_box import router as _box_router
from grid_editor_api_unified import router as _unified_router
# Re-export _build_grid_core so callers that do
# `from grid_editor_api import _build_grid_core` keep working.
from grid_build_core import _build_grid_core # noqa: F401
# Merge all sub-routers into one combined router
router = APIRouter()
router.include_router(_grid_router)
router.include_router(_gutter_router)
router.include_router(_box_router)
router.include_router(_unified_router)
@@ -0,0 +1,177 @@
"""
Grid Editor API — box-grid-review endpoints.
"""
import logging
from fastapi import APIRouter, HTTPException, Request
from grid_editor_helpers import _words_in_zone
from ocr_pipeline_session_store import (
get_session_db,
update_session_db,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["grid-editor"])
@router.post("/sessions/{session_id}/build-box-grids")
async def build_box_grids(session_id: str, request: Request):
"""Rebuild grid structure for all detected boxes with layout-aware detection.
Uses structure_result.boxes (from Step 7) as the source of box coordinates,
and raw_paddle_words as OCR word source. Creates or updates box zones in
the grid_editor_result.
Optional body: { "overrides": { "0": "bullet_list" } }
Maps box_index -> forced layout_type.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
# Get raw OCR words (with top/left/width/height keys)
word_result = session.get("word_result") or {}
all_words = word_result.get("raw_paddle_words") or word_result.get("raw_tesseract_words") or []
if not all_words:
raise HTTPException(status_code=400, detail="No raw OCR words available.")
# Get detected boxes from structure_result
structure_result = session.get("structure_result") or {}
gt = session.get("ground_truth") or {}
if not structure_result:
structure_result = gt.get("structure_result") or {}
detected_boxes = structure_result.get("boxes") or []
if not detected_boxes:
return {"session_id": session_id, "box_zones_rebuilt": 0, "spell_fixes": 0, "message": "No boxes detected"}
# Filter out false-positive boxes in header/footer margins.
img_h_for_filter = grid_data.get("image_height", 0) or word_result.get("image_height", 0)
if img_h_for_filter > 0:
margin_frac = 0.07 # 7% of image height
margin_top = img_h_for_filter * margin_frac
margin_bottom = img_h_for_filter * (1 - margin_frac)
filtered = []
for box in detected_boxes:
by = box.get("y", 0)
bh = box.get("h", 0)
box_center_y = by + bh / 2
if box_center_y < margin_top or box_center_y > margin_bottom:
logger.info("build-box-grids: skipping header/footer box at y=%d h=%d (center=%.0f, margins=%.0f/%.0f)",
by, bh, box_center_y, margin_top, margin_bottom)
continue
filtered.append(box)
detected_boxes = filtered
body = {}
try:
body = await request.json()
except Exception:
pass
layout_overrides = body.get("overrides", {})
from cv_box_layout import build_box_zone_grid
img_w = grid_data.get("image_width", 0) or word_result.get("image_width", 0)
img_h = grid_data.get("image_height", 0) or word_result.get("image_height", 0)
zones = grid_data.get("zones", [])
# Find highest existing zone_index
max_zone_idx = max((z.get("zone_index", 0) for z in zones), default=-1)
# Remove old box zones (we'll rebuild them)
zones = [z for z in zones if z.get("zone_type") != "box"]
box_count = 0
spell_fixes = 0
for box_idx, box in enumerate(detected_boxes):
bx = box.get("x", 0)
by = box.get("y", 0)
bw = box.get("w", 0)
bh = box.get("h", 0)
if bw <= 0 or bh <= 0:
continue
# Filter raw OCR words inside this box
zone_words = _words_in_zone(all_words, by, bh, bx, bw)
if not zone_words:
logger.info("Box %d: no words found in bbox (%d,%d,%d,%d)", box_idx, bx, by, bw, bh)
continue
zone_idx = max_zone_idx + 1 + box_idx
forced_layout = layout_overrides.get(str(box_idx))
# Build box grid
box_grid = build_box_zone_grid(
zone_words, bx, by, bw, bh,
zone_idx, img_w, img_h,
layout_type=forced_layout,
)
# Apply SmartSpellChecker to all box cells
try:
from smart_spell import SmartSpellChecker
ssc = SmartSpellChecker()
for cell in box_grid.get("cells", []):
text = cell.get("text", "")
if not text:
continue
result = ssc.correct_text(text, lang="auto")
if result.changed:
cell["text"] = result.corrected
spell_fixes += 1
except ImportError:
pass
# Build zone entry
zone_entry = {
"zone_index": zone_idx,
"zone_type": "box",
"bbox_px": {"x": bx, "y": by, "w": bw, "h": bh},
"bbox_pct": {
"x": round(bx / img_w * 100, 2) if img_w else 0,
"y": round(by / img_h * 100, 2) if img_h else 0,
"w": round(bw / img_w * 100, 2) if img_w else 0,
"h": round(bh / img_h * 100, 2) if img_h else 0,
},
"border": None,
"word_count": len(zone_words),
"columns": box_grid["columns"],
"rows": box_grid["rows"],
"cells": box_grid["cells"],
"header_rows": box_grid.get("header_rows", []),
"box_layout_type": box_grid.get("box_layout_type", "flowing"),
"box_grid_reviewed": False,
"box_bg_color": box.get("bg_color_name", ""),
"box_bg_hex": box.get("bg_color_hex", ""),
}
zones.append(zone_entry)
box_count += 1
# Sort zones by y-position for correct reading order
zones.sort(key=lambda z: z.get("bbox_px", {}).get("y", 0))
grid_data["zones"] = zones
await update_session_db(session_id, grid_editor_result=grid_data)
logger.info(
"build-box-grids session %s: %d boxes processed (%d words spell-fixed) from %d detected",
session_id, box_count, spell_fixes, len(detected_boxes),
)
return {
"session_id": session_id,
"box_zones_rebuilt": box_count,
"total_detected_boxes": len(detected_boxes),
"spell_fixes": spell_fixes,
"zones": zones,
}
@@ -0,0 +1,337 @@
"""
Grid Editor API — grid build, save, and retrieve endpoints.
"""
import logging
import time
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, Query, Request
from grid_build_core import _build_grid_core
from ocr_pipeline_session_store import (
get_session_db,
update_session_db,
)
from ocr_pipeline_common import (
_cache,
_load_session_to_cache,
_get_cached,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["grid-editor"])
@router.post("/sessions/{session_id}/build-grid")
async def build_grid(
session_id: str,
ipa_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
syllable_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
enhance: bool = Query(True, description="Step 3: CLAHE + denoise for degraded scans"),
max_cols: int = Query(0, description="Step 2: Max column count (0=unlimited)"),
min_conf: int = Query(0, description="Step 1: Min OCR confidence (0=auto)"),
):
"""Build a structured, zone-aware grid from existing Kombi word results.
Requires that paddle-kombi or rapid-kombi has already been run on the session.
Uses the image for box detection and the word positions for grid structuring.
Query params:
ipa_mode: "auto" (only when English IPA detected), "all" (force), "none" (skip)
syllable_mode: "auto" (only when original has dividers), "all" (force), "none" (skip)
Returns a StructuredGrid with zones, each containing their own
columns, rows, and cells — ready for the frontend Excel-like editor.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
try:
result = await _build_grid_core(
session_id, session,
ipa_mode=ipa_mode, syllable_mode=syllable_mode,
enhance=enhance,
max_columns=max_cols if max_cols > 0 else None,
min_conf=min_conf if min_conf > 0 else None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Save automatic grid snapshot for later comparison with manual corrections
# Lazy import to avoid circular dependency with ocr_pipeline_regression
from ocr_pipeline_regression import _build_reference_snapshot
wr = session.get("word_result") or {}
engine = wr.get("ocr_engine", "")
if engine in ("kombi", "rapid_kombi"):
auto_pipeline = "kombi"
elif engine == "paddle_direct":
auto_pipeline = "paddle-direct"
else:
auto_pipeline = "pipeline"
auto_snapshot = _build_reference_snapshot(result, pipeline=auto_pipeline)
gt = session.get("ground_truth") or {}
gt["auto_grid_snapshot"] = auto_snapshot
# Persist to DB and advance current_step to 11 (reconstruction complete)
await update_session_db(session_id, grid_editor_result=result, ground_truth=gt, current_step=11)
logger.info(
"build-grid session %s: %d zones, %d cols, %d rows, %d cells, "
"%d boxes in %.2fs",
session_id,
len(result.get("zones", [])),
result.get("summary", {}).get("total_columns", 0),
result.get("summary", {}).get("total_rows", 0),
result.get("summary", {}).get("total_cells", 0),
result.get("boxes_detected", 0),
result.get("duration_seconds", 0),
)
return result
@router.post("/sessions/{session_id}/rerun-ocr-and-build-grid")
async def rerun_ocr_and_build_grid(
session_id: str,
ipa_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
syllable_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"),
enhance: bool = Query(True, description="Step 3: CLAHE + denoise for degraded scans"),
max_cols: int = Query(0, description="Step 2: Max column count (0=unlimited)"),
min_conf: int = Query(0, description="Step 1: Min OCR confidence (0=auto)"),
vision_fusion: bool = Query(False, description="Step 4: Vision-LLM fusion for degraded scans"),
doc_category: str = Query("", description="Document type for Vision-LLM prompt context"),
):
"""Re-run OCR with quality settings, then rebuild the grid.
Unlike build-grid (which only rebuilds from existing words),
this endpoint re-runs the full OCR pipeline on the cropped image
with optional CLAHE enhancement, then builds the grid.
Steps executed: Image Enhancement -> OCR -> Grid Build
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
import time as _time
t0 = _time.time()
# 1. Load the cropped/dewarped image from cache or session
if session_id not in _cache:
await _load_session_to_cache(session_id)
cached = _get_cached(session_id)
dewarped_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr")
if dewarped_bgr is None:
raise HTTPException(status_code=400, detail="No cropped/dewarped image available. Run preprocessing steps first.")
import numpy as np
img_h, img_w = dewarped_bgr.shape[:2]
ocr_input = dewarped_bgr.copy()
# 2. Scan quality assessment
scan_quality_info = {}
try:
from scan_quality import score_scan_quality
quality_report = score_scan_quality(ocr_input)
scan_quality_info = quality_report.to_dict()
actual_min_conf = min_conf if min_conf > 0 else quality_report.recommended_min_conf
except Exception as e:
logger.warning(f"rerun-ocr: scan quality failed: {e}")
actual_min_conf = min_conf if min_conf > 0 else 40
# 3. Image enhancement (Step 3)
is_degraded = scan_quality_info.get("is_degraded", False)
if enhance and is_degraded:
try:
from ocr_image_enhance import enhance_for_ocr
ocr_input = enhance_for_ocr(ocr_input, is_degraded=True)
logger.info("rerun-ocr: CLAHE enhancement applied")
except Exception as e:
logger.warning(f"rerun-ocr: enhancement failed: {e}")
# 4. Run dual-engine OCR
from PIL import Image
import pytesseract
# RapidOCR
rapid_words = []
try:
from cv_ocr_engines import ocr_region_rapid
from cv_vocab_types import PageRegion
full_region = PageRegion(type="full_page", x=0, y=0, width=img_w, height=img_h)
rapid_words = ocr_region_rapid(ocr_input, full_region) or []
except Exception as e:
logger.warning(f"rerun-ocr: RapidOCR failed: {e}")
# Tesseract
pil_img = Image.fromarray(ocr_input[:, :, ::-1])
data = pytesseract.image_to_data(pil_img, lang='eng+deu', config='--psm 6 --oem 3', output_type=pytesseract.Output.DICT)
tess_words = []
for i in range(len(data["text"])):
text = (data["text"][i] or "").strip()
conf_raw = str(data["conf"][i])
conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1
if not text or conf < actual_min_conf:
continue
tess_words.append({
"text": text, "left": data["left"][i], "top": data["top"][i],
"width": data["width"][i], "height": data["height"][i], "conf": conf,
})
# 5. Merge OCR results
from ocr_pipeline_ocr_merge import _split_paddle_multi_words, _merge_paddle_tesseract, _deduplicate_words
rapid_split = _split_paddle_multi_words(rapid_words) if rapid_words else []
if rapid_split or tess_words:
merged_words = _merge_paddle_tesseract(rapid_split, tess_words)
merged_words = _deduplicate_words(merged_words)
else:
merged_words = tess_words
# 6. Store updated word_result in session
cells_for_storage = [{"text": w["text"], "left": w["left"], "top": w["top"],
"width": w["width"], "height": w["height"], "conf": w.get("conf", 0)}
for w in merged_words]
word_result = {
"cells": [{"text": " ".join(w["text"] for w in merged_words),
"word_boxes": cells_for_storage}],
"image_width": img_w,
"image_height": img_h,
"ocr_engine": "rapid_kombi",
"word_count": len(merged_words),
"raw_paddle_words": rapid_words,
}
# 6b. Vision-LLM Fusion (Step 4) — correct OCR using Vision model
vision_applied = False
if vision_fusion:
try:
from vision_ocr_fusion import vision_fuse_ocr
category = doc_category or session.get("document_category") or "vokabelseite"
logger.info(f"rerun-ocr: running Vision-LLM fusion (category={category})")
merged_words = await vision_fuse_ocr(ocr_input, merged_words, category)
vision_applied = True
# Rebuild storage from fused words
cells_for_storage = [{"text": w["text"], "left": w["left"], "top": w["top"],
"width": w["width"], "height": w["height"], "conf": w.get("conf", 0)}
for w in merged_words]
word_result["cells"] = [{"text": " ".join(w["text"] for w in merged_words),
"word_boxes": cells_for_storage}]
word_result["word_count"] = len(merged_words)
word_result["ocr_engine"] = "vision_fusion"
except Exception as e:
logger.warning(f"rerun-ocr: Vision-LLM fusion failed: {e}")
await update_session_db(session_id, word_result=word_result)
# Reload session with updated word_result
session = await get_session_db(session_id)
ocr_duration = _time.time() - t0
logger.info(
"rerun-ocr session %s: %d words (rapid=%d, tess=%d, merged=%d) in %.1fs "
"(enhance=%s, min_conf=%d, quality=%s)",
session_id, len(merged_words), len(rapid_words), len(tess_words),
len(merged_words), ocr_duration, enhance, actual_min_conf,
scan_quality_info.get("quality_pct", "?"),
)
# 7. Build grid from new words
try:
result = await _build_grid_core(
session_id, session,
ipa_mode=ipa_mode, syllable_mode=syllable_mode,
enhance=enhance,
max_columns=max_cols if max_cols > 0 else None,
min_conf=min_conf if min_conf > 0 else None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Persist grid
await update_session_db(session_id, grid_editor_result=result, current_step=11)
# Add quality info to response
result["scan_quality"] = scan_quality_info
result["ocr_stats"] = {
"rapid_words": len(rapid_words),
"tess_words": len(tess_words),
"merged_words": len(merged_words),
"min_conf_used": actual_min_conf,
"enhance_applied": enhance and is_degraded,
"vision_fusion_applied": vision_applied,
"document_category": doc_category or session.get("document_category", ""),
"ocr_duration_seconds": round(ocr_duration, 1),
}
total_duration = _time.time() - t0
logger.info(
"rerun-ocr+build-grid session %s: %d zones, %d cols, %d cells in %.1fs",
session_id,
len(result.get("zones", [])),
result.get("summary", {}).get("total_columns", 0),
result.get("summary", {}).get("total_cells", 0),
total_duration,
)
return result
@router.post("/sessions/{session_id}/save-grid")
async def save_grid(session_id: str, request: Request):
"""Save edited grid data from the frontend Excel-like editor.
Receives the full StructuredGrid with user edits (text changes,
formatting changes like bold columns, header rows, etc.) and
persists it to the session's grid_editor_result.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
body = await request.json()
# Validate basic structure
if "zones" not in body:
raise HTTPException(status_code=400, detail="Missing 'zones' in request body")
# Preserve metadata from the original build
existing = session.get("grid_editor_result") or {}
result = {
"session_id": session_id,
"image_width": body.get("image_width", existing.get("image_width", 0)),
"image_height": body.get("image_height", existing.get("image_height", 0)),
"zones": body["zones"],
"boxes_detected": body.get("boxes_detected", existing.get("boxes_detected", 0)),
"summary": body.get("summary", existing.get("summary", {})),
"formatting": body.get("formatting", existing.get("formatting", {})),
"duration_seconds": existing.get("duration_seconds", 0),
"edited": True,
}
await update_session_db(session_id, grid_editor_result=result, current_step=11)
logger.info("save-grid session %s: %d zones saved", session_id, len(body["zones"]))
return {"session_id": session_id, "saved": True}
@router.get("/sessions/{session_id}/grid-editor")
async def get_grid(session_id: str):
"""Retrieve the current grid editor state for a session."""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
result = session.get("grid_editor_result")
if not result:
raise HTTPException(
status_code=404,
detail="No grid editor data. Run build-grid first.",
)
return result
@@ -0,0 +1,110 @@
"""
Grid Editor API — gutter repair endpoints.
"""
import logging
from fastapi import APIRouter, HTTPException, Request
from ocr_pipeline_session_store import (
get_session_db,
update_session_db,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["grid-editor"])
@router.post("/sessions/{session_id}/gutter-repair")
async def gutter_repair(session_id: str):
"""Analyse grid for gutter-edge OCR errors and return repair suggestions.
Detects:
- Words truncated/blurred at the book binding (spell_fix)
- Words split across rows with missing hyphen chars (hyphen_join)
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(
status_code=400,
detail="No grid data. Run build-grid first.",
)
from cv_gutter_repair import analyse_grid_for_gutter_repair
image_width = grid_data.get("image_width", 0)
result = analyse_grid_for_gutter_repair(grid_data, image_width=image_width)
# Persist suggestions in ground_truth.gutter_repair (avoids DB migration)
gt = session.get("ground_truth") or {}
gt["gutter_repair"] = result
await update_session_db(session_id, ground_truth=gt)
logger.info(
"gutter-repair session %s: %d suggestions in %.2fs",
session_id,
result.get("stats", {}).get("suggestions_found", 0),
result.get("duration_seconds", 0),
)
return result
@router.post("/sessions/{session_id}/gutter-repair/apply")
async def gutter_repair_apply(session_id: str, request: Request):
"""Apply accepted gutter repair suggestions to the grid.
Body: { "accepted": ["suggestion_id_1", "suggestion_id_2", ...] }
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data.")
gt = session.get("ground_truth") or {}
gutter_result = gt.get("gutter_repair")
if not gutter_result:
raise HTTPException(
status_code=400,
detail="No gutter repair data. Run gutter-repair first.",
)
body = await request.json()
accepted_ids = body.get("accepted", [])
if not accepted_ids:
return {"applied_count": 0, "changes": []}
# text_overrides: { suggestion_id: "alternative_text" }
# Allows the user to pick a different correction from the alternatives list
text_overrides = body.get("text_overrides", {})
from cv_gutter_repair import apply_gutter_suggestions
suggestions = gutter_result.get("suggestions", [])
# Apply user-selected alternatives before passing to apply
for s in suggestions:
sid = s.get("id", "")
if sid in text_overrides and text_overrides[sid]:
s["suggested_text"] = text_overrides[sid]
result = apply_gutter_suggestions(grid_data, accepted_ids, suggestions)
# Save updated grid back to session
await update_session_db(session_id, grid_editor_result=grid_data)
logger.info(
"gutter-repair/apply session %s: %d changes applied",
session_id,
result.get("applied_count", 0),
)
return result
@@ -0,0 +1,71 @@
"""
Grid Editor API — unified grid endpoints.
"""
import logging
from fastapi import APIRouter, HTTPException
from ocr_pipeline_session_store import (
get_session_db,
update_session_db,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["grid-editor"])
@router.post("/sessions/{session_id}/build-unified-grid")
async def build_unified_grid_endpoint(session_id: str):
"""Build a single-zone unified grid merging content + box zones.
Takes the existing multi-zone grid_editor_result and produces a
unified grid where boxes are integrated into the main row sequence.
Persists as unified_grid_result (preserves original multi-zone data).
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
from unified_grid import build_unified_grid
result = build_unified_grid(
zones=grid_data.get("zones", []),
image_width=grid_data.get("image_width", 0),
image_height=grid_data.get("image_height", 0),
layout_metrics=grid_data.get("layout_metrics", {}),
)
# Persist as separate field (don't overwrite original multi-zone grid)
await update_session_db(session_id, unified_grid_result=result)
logger.info(
"build-unified-grid session %s: %d rows, %d cells",
session_id,
result.get("summary", {}).get("total_rows", 0),
result.get("summary", {}).get("total_cells", 0),
)
return result
@router.get("/sessions/{session_id}/unified-grid")
async def get_unified_grid(session_id: str):
"""Retrieve the unified grid for a session."""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
result = session.get("unified_grid_result")
if not result:
raise HTTPException(
status_code=404,
detail="No unified grid. Run build-unified-grid first.",
)
return result
+25 -609
View File
@@ -1,68 +1,43 @@
"""
Unified Inbox Mail API
Unified Inbox Mail API — barrel re-export.
FastAPI router for the mail system.
The actual endpoints live in:
- api_accounts.py (account CRUD, test, sync)
- api_inbox.py (unified inbox, email detail, send)
- api_ai.py (AI analysis, response suggestions)
- api_tasks.py (task CRUD, dashboard, from-email)
"""
import logging
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Query
from .models import (
EmailAccountCreate,
EmailAccountUpdate,
EmailAccount,
AccountTestResult,
AggregatedEmail,
EmailSearchParams,
TaskCreate,
TaskUpdate,
InboxTask,
TaskDashboardStats,
EmailComposeRequest,
EmailSendResult,
MailStats,
MailHealthCheck,
EmailAnalysisResult,
ResponseSuggestion,
TaskStatus,
TaskPriority,
EmailCategory,
)
from .mail_db import (
init_mail_tables,
create_email_account,
get_email_accounts,
get_email_account,
delete_email_account,
get_unified_inbox,
get_email,
mark_email_read,
mark_email_starred,
get_mail_stats,
log_mail_audit,
)
from .credentials import get_credentials_service
from .aggregator import get_mail_aggregator
from .ai_service import get_ai_email_service
from .task_service import get_task_service
from .models import MailHealthCheck, MailStats
from .mail_db import init_mail_tables, get_mail_stats
from .api_accounts import router as _accounts_router
from .api_inbox import router as _inbox_router
from .api_ai import router as _ai_router
from .api_tasks import router as _tasks_router
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
router = APIRouter()
# Merge sub-routers
router.include_router(_accounts_router)
router.include_router(_inbox_router)
router.include_router(_ai_router)
router.include_router(_tasks_router)
# =============================================================================
# Health & Init
# Health & Init (kept here as they are small)
# =============================================================================
@router.get("/health", response_model=MailHealthCheck)
@router.get("/api/v1/mail/health", response_model=MailHealthCheck)
async def health_check():
"""Health check for the mail system."""
# TODO: Implement full health check
return MailHealthCheck(
status="healthy",
database_connected=True,
@@ -70,7 +45,7 @@ async def health_check():
)
@router.post("/init")
@router.post("/api/v1/mail/init")
async def initialize_mail_system():
"""Initialize mail database tables."""
success = await init_mail_tables()
@@ -79,573 +54,14 @@ async def initialize_mail_system():
return {"status": "initialized"}
# =============================================================================
# Account Management
# =============================================================================
class AccountCreateRequest(BaseModel):
"""Request to create an email account."""
email: str
display_name: str
account_type: str = "personal"
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts", response_model=dict)
async def create_account(
request: AccountCreateRequest,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new email account."""
credentials_service = get_credentials_service()
# Store credentials securely
vault_path = await credentials_service.store_credentials(
account_id=f"{user_id}_{request.email}",
email=request.email,
password=request.password,
imap_host=request.imap_host,
imap_port=request.imap_port,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
)
# Create account in database
account_id = await create_email_account(
user_id=user_id,
tenant_id=tenant_id,
email=request.email,
display_name=request.display_name,
account_type=request.account_type,
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
vault_path=vault_path,
)
if not account_id:
raise HTTPException(status_code=500, detail="Failed to create account")
# Log audit
await log_mail_audit(
user_id=user_id,
action="account_created",
entity_type="account",
entity_id=account_id,
details={"email": request.email},
tenant_id=tenant_id,
)
return {"id": account_id, "status": "created"}
@router.get("/accounts", response_model=List[dict])
async def list_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None, description="Tenant ID"),
):
"""List all email accounts for a user."""
accounts = await get_email_accounts(user_id, tenant_id)
# Remove sensitive fields
for account in accounts:
account.pop("vault_path", None)
return accounts
@router.get("/accounts/{account_id}", response_model=dict)
async def get_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
account.pop("vault_path", None)
return account
@router.delete("/accounts/{account_id}")
async def remove_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Delete an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Delete credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
if vault_path:
await credentials_service.delete_credentials(account_id, vault_path)
# Delete from database (cascades to emails)
success = await delete_email_account(account_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete account")
await log_mail_audit(
user_id=user_id,
action="account_deleted",
entity_type="account",
entity_id=account_id,
)
return {"status": "deleted"}
@router.post("/accounts/{account_id}/test", response_model=AccountTestResult)
async def test_account_connection(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Test connection for an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Get credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
creds = await credentials_service.get_credentials(account_id, vault_path)
if not creds:
return AccountTestResult(
success=False,
error_message="Credentials not found"
)
# Test connection
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=account["imap_host"],
imap_port=account["imap_port"],
imap_ssl=account["imap_ssl"],
smtp_host=account["smtp_host"],
smtp_port=account["smtp_port"],
smtp_ssl=account["smtp_ssl"],
email_address=creds.email,
password=creds.password,
)
return result
class ConnectionTestRequest(BaseModel):
"""Request to test connection before saving account."""
email: str
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts/test-connection", response_model=AccountTestResult)
async def test_connection_before_save(request: ConnectionTestRequest):
"""
Test IMAP/SMTP connection before saving an account.
This allows the wizard to verify credentials are correct
before creating the account in the database.
"""
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
email_address=request.email,
password=request.password,
)
return result
@router.post("/accounts/{account_id}/sync")
async def sync_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
max_emails: int = Query(100, ge=1, le=500),
background_tasks: BackgroundTasks = None,
):
"""Sync emails from an account."""
aggregator = get_mail_aggregator()
try:
new_count, total_count = await aggregator.sync_account(
account_id=account_id,
user_id=user_id,
max_emails=max_emails,
)
return {
"status": "synced",
"new_emails": new_count,
"total_emails": total_count,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# Unified Inbox
# =============================================================================
@router.get("/inbox", response_model=List[dict])
async def get_inbox(
user_id: str = Query(..., description="User ID"),
account_ids: Optional[str] = Query(None, description="Comma-separated account IDs"),
categories: Optional[str] = Query(None, description="Comma-separated categories"),
is_read: Optional[bool] = Query(None),
is_starred: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get unified inbox with all accounts aggregated."""
# Parse comma-separated values
account_id_list = account_ids.split(",") if account_ids else None
category_list = categories.split(",") if categories else None
emails = await get_unified_inbox(
user_id=user_id,
account_ids=account_id_list,
categories=category_list,
is_read=is_read,
is_starred=is_starred,
limit=limit,
offset=offset,
)
return emails
@router.get("/inbox/{email_id}", response_model=dict)
async def get_email_detail(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email with full details."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Mark as read
await mark_email_read(email_id, user_id, is_read=True)
return email_data
@router.post("/inbox/{email_id}/read")
async def mark_read(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_read: bool = Query(True),
):
"""Mark email as read/unread."""
success = await mark_email_read(email_id, user_id, is_read)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_read": is_read}
@router.post("/inbox/{email_id}/star")
async def mark_starred(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_starred: bool = Query(True),
):
"""Mark email as starred/unstarred."""
success = await mark_email_starred(email_id, user_id, is_starred)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_starred": is_starred}
# =============================================================================
# Send Email
# =============================================================================
@router.post("/send", response_model=EmailSendResult)
async def send_email(
request: EmailComposeRequest,
user_id: str = Query(..., description="User ID"),
):
"""Send an email."""
aggregator = get_mail_aggregator()
result = await aggregator.send_email(
account_id=request.account_id,
user_id=user_id,
request=request,
)
if result.success:
await log_mail_audit(
user_id=user_id,
action="email_sent",
entity_type="email",
details={
"account_id": request.account_id,
"to": request.to,
"subject": request.subject,
},
)
return result
# =============================================================================
# AI Analysis
# =============================================================================
@router.post("/analyze/{email_id}", response_model=EmailAnalysisResult)
async def analyze_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Run AI analysis on an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
result = await ai_service.analyze_email(
email_id=email_id,
sender_email=email_data.get("sender_email", ""),
sender_name=email_data.get("sender_name"),
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text"),
body_preview=email_data.get("body_preview"),
)
return result
@router.get("/suggestions/{email_id}", response_model=List[ResponseSuggestion])
async def get_response_suggestions(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get AI-generated response suggestions for an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
# Use stored analysis if available
from .models import SenderType, EmailCategory as EC
sender_type = SenderType(email_data.get("sender_type", "unbekannt"))
category = EC(email_data.get("category", "sonstiges"))
suggestions = await ai_service.suggest_response(
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text", ""),
sender_type=sender_type,
category=category,
)
return suggestions
# =============================================================================
# Tasks (Arbeitsvorrat)
# =============================================================================
@router.get("/tasks", response_model=List[dict])
async def list_tasks(
user_id: str = Query(..., description="User ID"),
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
include_completed: bool = Query(False),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get all tasks for a user."""
task_service = get_task_service()
status_enum = TaskStatus(status) if status else None
priority_enum = TaskPriority(priority) if priority else None
tasks = await task_service.get_user_tasks(
user_id=user_id,
status=status_enum,
priority=priority_enum,
include_completed=include_completed,
limit=limit,
offset=offset,
)
return tasks
@router.post("/tasks", response_model=dict)
async def create_task(
request: TaskCreate,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new task manually."""
task_service = get_task_service()
task_id = await task_service.create_manual_task(
user_id=user_id,
tenant_id=tenant_id,
task_data=request,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
@router.get("/tasks/dashboard", response_model=TaskDashboardStats)
async def get_task_dashboard(
user_id: str = Query(..., description="User ID"),
):
"""Get dashboard statistics for tasks."""
task_service = get_task_service()
return await task_service.get_dashboard_stats(user_id)
@router.get("/tasks/{task_id}", response_model=dict)
async def get_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single task."""
task_service = get_task_service()
task = await task_service.get_task(task_id, user_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.put("/tasks/{task_id}")
async def update_task(
task_id: str,
request: TaskUpdate,
user_id: str = Query(..., description="User ID"),
):
"""Update a task."""
task_service = get_task_service()
success = await task_service.update_task(task_id, user_id, request)
if not success:
raise HTTPException(status_code=500, detail="Failed to update task")
return {"status": "updated"}
@router.post("/tasks/{task_id}/complete")
async def complete_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Mark a task as completed."""
task_service = get_task_service()
success = await task_service.mark_completed(task_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to complete task")
return {"status": "completed"}
@router.post("/tasks/from-email/{email_id}")
async def create_task_from_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a task from an email (after analysis)."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Get deadlines from stored analysis
deadlines_raw = email_data.get("detected_deadlines", [])
from .models import DeadlineExtraction, SenderType
deadlines = []
for d in deadlines_raw:
try:
deadlines.append(DeadlineExtraction(
deadline_date=datetime.fromisoformat(d["date"]),
description=d.get("description", "Frist"),
confidence=0.8,
source_text="",
is_firm=d.get("is_firm", True),
))
except (KeyError, ValueError):
continue
sender_type = None
if email_data.get("sender_type"):
try:
sender_type = SenderType(email_data["sender_type"])
except ValueError:
pass
task_service = get_task_service()
task_id = await task_service.create_task_from_email(
user_id=user_id,
tenant_id=tenant_id,
email_id=email_id,
deadlines=deadlines,
sender_type=sender_type,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
# =============================================================================
# Statistics
# =============================================================================
@router.get("/stats", response_model=MailStats)
@router.get("/api/v1/mail/stats", response_model=MailStats)
async def get_statistics(
user_id: str = Query(..., description="User ID"),
):
"""Get overall mail statistics for a user."""
stats = await get_mail_stats(user_id)
return MailStats(**stats)
# =============================================================================
# Sync All
# =============================================================================
@router.post("/sync-all")
async def sync_all_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None),
):
"""Sync all email accounts for a user."""
aggregator = get_mail_aggregator()
results = await aggregator.sync_all_accounts(user_id, tenant_id)
return {"status": "synced", "results": results}
@@ -0,0 +1,258 @@
"""
Mail API — account management and sync endpoints.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
from pydantic import BaseModel
from .models import AccountTestResult
from .mail_db import (
create_email_account,
get_email_accounts,
get_email_account,
delete_email_account,
log_mail_audit,
)
from .credentials import get_credentials_service
from .aggregator import get_mail_aggregator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
class AccountCreateRequest(BaseModel):
"""Request to create an email account."""
email: str
display_name: str
account_type: str = "personal"
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts", response_model=dict)
async def create_account(
request: AccountCreateRequest,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new email account."""
credentials_service = get_credentials_service()
# Store credentials securely
vault_path = await credentials_service.store_credentials(
account_id=f"{user_id}_{request.email}",
email=request.email,
password=request.password,
imap_host=request.imap_host,
imap_port=request.imap_port,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
)
# Create account in database
account_id = await create_email_account(
user_id=user_id,
tenant_id=tenant_id,
email=request.email,
display_name=request.display_name,
account_type=request.account_type,
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
vault_path=vault_path,
)
if not account_id:
raise HTTPException(status_code=500, detail="Failed to create account")
# Log audit
await log_mail_audit(
user_id=user_id,
action="account_created",
entity_type="account",
entity_id=account_id,
details={"email": request.email},
tenant_id=tenant_id,
)
return {"id": account_id, "status": "created"}
@router.get("/accounts", response_model=List[dict])
async def list_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None, description="Tenant ID"),
):
"""List all email accounts for a user."""
accounts = await get_email_accounts(user_id, tenant_id)
# Remove sensitive fields
for account in accounts:
account.pop("vault_path", None)
return accounts
@router.get("/accounts/{account_id}", response_model=dict)
async def get_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
account.pop("vault_path", None)
return account
@router.delete("/accounts/{account_id}")
async def remove_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Delete an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Delete credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
if vault_path:
await credentials_service.delete_credentials(account_id, vault_path)
# Delete from database (cascades to emails)
success = await delete_email_account(account_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete account")
await log_mail_audit(
user_id=user_id,
action="account_deleted",
entity_type="account",
entity_id=account_id,
)
return {"status": "deleted"}
@router.post("/accounts/{account_id}/test", response_model=AccountTestResult)
async def test_account_connection(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Test connection for an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Get credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
creds = await credentials_service.get_credentials(account_id, vault_path)
if not creds:
return AccountTestResult(
success=False,
error_message="Credentials not found"
)
# Test connection
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=account["imap_host"],
imap_port=account["imap_port"],
imap_ssl=account["imap_ssl"],
smtp_host=account["smtp_host"],
smtp_port=account["smtp_port"],
smtp_ssl=account["smtp_ssl"],
email_address=creds.email,
password=creds.password,
)
return result
class ConnectionTestRequest(BaseModel):
"""Request to test connection before saving account."""
email: str
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts/test-connection", response_model=AccountTestResult)
async def test_connection_before_save(request: ConnectionTestRequest):
"""
Test IMAP/SMTP connection before saving an account.
This allows the wizard to verify credentials are correct
before creating the account in the database.
"""
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
email_address=request.email,
password=request.password,
)
return result
@router.post("/accounts/{account_id}/sync")
async def sync_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
max_emails: int = Query(100, ge=1, le=500),
background_tasks: BackgroundTasks = None,
):
"""Sync emails from an account."""
aggregator = get_mail_aggregator()
try:
new_count, total_count = await aggregator.sync_account(
account_id=account_id,
user_id=user_id,
max_emails=max_emails,
)
return {
"status": "synced",
"new_emails": new_count,
"total_emails": total_count,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync-all")
async def sync_all_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None),
):
"""Sync all email accounts for a user."""
aggregator = get_mail_aggregator()
results = await aggregator.sync_all_accounts(user_id, tenant_id)
return {"status": "synced", "results": results}
+69
View File
@@ -0,0 +1,69 @@
"""
Mail API — AI analysis and response suggestion endpoints.
"""
import logging
from typing import List
from fastapi import APIRouter, HTTPException, Query
from .models import (
EmailAnalysisResult,
ResponseSuggestion,
)
from .mail_db import get_email
from .ai_service import get_ai_email_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
@router.post("/analyze/{email_id}", response_model=EmailAnalysisResult)
async def analyze_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Run AI analysis on an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
result = await ai_service.analyze_email(
email_id=email_id,
sender_email=email_data.get("sender_email", ""),
sender_name=email_data.get("sender_name"),
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text"),
body_preview=email_data.get("body_preview"),
)
return result
@router.get("/suggestions/{email_id}", response_model=List[ResponseSuggestion])
async def get_response_suggestions(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get AI-generated response suggestions for an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
# Use stored analysis if available
from .models import SenderType, EmailCategory as EC
sender_type = SenderType(email_data.get("sender_type", "unbekannt"))
category = EC(email_data.get("category", "sonstiges"))
suggestions = await ai_service.suggest_response(
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text", ""),
sender_type=sender_type,
category=category,
)
return suggestions
+123
View File
@@ -0,0 +1,123 @@
"""
Mail API — unified inbox, send, and email detail endpoints.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query
from .models import (
EmailComposeRequest,
EmailSendResult,
)
from .mail_db import (
get_unified_inbox,
get_email,
mark_email_read,
mark_email_starred,
log_mail_audit,
)
from .aggregator import get_mail_aggregator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
@router.get("/inbox", response_model=List[dict])
async def get_inbox(
user_id: str = Query(..., description="User ID"),
account_ids: Optional[str] = Query(None, description="Comma-separated account IDs"),
categories: Optional[str] = Query(None, description="Comma-separated categories"),
is_read: Optional[bool] = Query(None),
is_starred: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get unified inbox with all accounts aggregated."""
# Parse comma-separated values
account_id_list = account_ids.split(",") if account_ids else None
category_list = categories.split(",") if categories else None
emails = await get_unified_inbox(
user_id=user_id,
account_ids=account_id_list,
categories=category_list,
is_read=is_read,
is_starred=is_starred,
limit=limit,
offset=offset,
)
return emails
@router.get("/inbox/{email_id}", response_model=dict)
async def get_email_detail(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email with full details."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Mark as read
await mark_email_read(email_id, user_id, is_read=True)
return email_data
@router.post("/inbox/{email_id}/read")
async def mark_read(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_read: bool = Query(True),
):
"""Mark email as read/unread."""
success = await mark_email_read(email_id, user_id, is_read)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_read": is_read}
@router.post("/inbox/{email_id}/star")
async def mark_starred(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_starred: bool = Query(True),
):
"""Mark email as starred/unstarred."""
success = await mark_email_starred(email_id, user_id, is_starred)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_starred": is_starred}
@router.post("/send", response_model=EmailSendResult)
async def send_email(
request: EmailComposeRequest,
user_id: str = Query(..., description="User ID"),
):
"""Send an email."""
aggregator = get_mail_aggregator()
result = await aggregator.send_email(
account_id=request.account_id,
user_id=user_id,
request=request,
)
if result.success:
await log_mail_audit(
user_id=user_id,
action="email_sent",
entity_type="email",
details={
"account_id": request.account_id,
"to": request.to,
"subject": request.subject,
},
)
return result
+176
View File
@@ -0,0 +1,176 @@
"""
Mail API — task (Arbeitsvorrat) endpoints.
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Query
from .models import (
TaskCreate,
TaskUpdate,
TaskDashboardStats,
TaskStatus,
TaskPriority,
)
from .mail_db import get_email
from .task_service import get_task_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
@router.get("/tasks", response_model=List[dict])
async def list_tasks(
user_id: str = Query(..., description="User ID"),
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
include_completed: bool = Query(False),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get all tasks for a user."""
task_service = get_task_service()
status_enum = TaskStatus(status) if status else None
priority_enum = TaskPriority(priority) if priority else None
tasks = await task_service.get_user_tasks(
user_id=user_id,
status=status_enum,
priority=priority_enum,
include_completed=include_completed,
limit=limit,
offset=offset,
)
return tasks
@router.post("/tasks", response_model=dict)
async def create_task(
request: TaskCreate,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new task manually."""
task_service = get_task_service()
task_id = await task_service.create_manual_task(
user_id=user_id,
tenant_id=tenant_id,
task_data=request,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
@router.get("/tasks/dashboard", response_model=TaskDashboardStats)
async def get_task_dashboard(
user_id: str = Query(..., description="User ID"),
):
"""Get dashboard statistics for tasks."""
task_service = get_task_service()
return await task_service.get_dashboard_stats(user_id)
@router.get("/tasks/{task_id}", response_model=dict)
async def get_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single task."""
task_service = get_task_service()
task = await task_service.get_task(task_id, user_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.put("/tasks/{task_id}")
async def update_task(
task_id: str,
request: TaskUpdate,
user_id: str = Query(..., description="User ID"),
):
"""Update a task."""
task_service = get_task_service()
success = await task_service.update_task(task_id, user_id, request)
if not success:
raise HTTPException(status_code=500, detail="Failed to update task")
return {"status": "updated"}
@router.post("/tasks/{task_id}/complete")
async def complete_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Mark a task as completed."""
task_service = get_task_service()
success = await task_service.mark_completed(task_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to complete task")
return {"status": "completed"}
@router.post("/tasks/from-email/{email_id}")
async def create_task_from_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a task from an email (after analysis)."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Get deadlines from stored analysis
deadlines_raw = email_data.get("detected_deadlines", [])
from .models import DeadlineExtraction, SenderType
deadlines = []
for d in deadlines_raw:
try:
deadlines.append(DeadlineExtraction(
deadline_date=datetime.fromisoformat(d["date"]),
description=d.get("description", "Frist"),
confidence=0.8,
source_text="",
is_firm=d.get("is_firm", True),
))
except (KeyError, ValueError):
continue
sender_type = None
if email_data.get("sender_type"):
try:
sender_type = SenderType(email_data["sender_type"])
except ValueError:
pass
task_service = get_task_service()
task_id = await task_service.create_task_from_email(
user_id=user_id,
tenant_id=tenant_id,
email_id=email_id,
deadlines=deadlines,
sender_type=sender_type,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
+188
View File
@@ -0,0 +1,188 @@
"""
Orientation & Page-Split API endpoints (Steps 1 and 1b of OCR Pipeline).
"""
import logging
import time
from typing import Any, Dict
import cv2
from fastapi import APIRouter, HTTPException
from cv_vocab_pipeline import detect_and_fix_orientation
from page_crop import detect_page_splits
from ocr_pipeline_session_store import update_session_db
from orientation_crop_helpers import ensure_cached, append_pipeline_log
from page_sub_sessions import create_page_sub_sessions_full
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
# ---------------------------------------------------------------------------
# Step 1: Orientation
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/orientation")
async def detect_orientation(session_id: str):
"""Detect and fix 90/180/270 degree rotations from scanners.
Reads the original image, applies orientation correction,
stores the result as oriented_png.
"""
cached = await ensure_cached(session_id)
img_bgr = cached.get("original_bgr")
if img_bgr is None:
raise HTTPException(status_code=400, detail="Original image not available")
t0 = time.time()
# Detect and fix orientation
oriented_bgr, orientation_deg = detect_and_fix_orientation(img_bgr.copy())
duration = time.time() - t0
orientation_result = {
"orientation_degrees": orientation_deg,
"corrected": orientation_deg != 0,
"duration_seconds": round(duration, 2),
}
# Encode oriented image
success, png_buf = cv2.imencode(".png", oriented_bgr)
oriented_png = png_buf.tobytes() if success else b""
# Update cache
cached["oriented_bgr"] = oriented_bgr
cached["orientation_result"] = orientation_result
# Persist to DB
await update_session_db(
session_id,
oriented_png=oriented_png,
orientation_result=orientation_result,
current_step=2,
)
logger.info(
"OCR Pipeline: orientation session %s: %d° (%s) in %.2fs",
session_id, orientation_deg,
"corrected" if orientation_deg else "no change",
duration,
)
await append_pipeline_log(session_id, "orientation", {
"orientation_degrees": orientation_deg,
"corrected": orientation_deg != 0,
}, duration_ms=int(duration * 1000))
h, w = oriented_bgr.shape[:2]
return {
"session_id": session_id,
**orientation_result,
"image_width": w,
"image_height": h,
"oriented_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/oriented",
}
# ---------------------------------------------------------------------------
# Step 1b: Page-split detection — runs AFTER orientation, BEFORE deskew
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/page-split")
async def detect_page_split(session_id: str):
"""Detect if the image is a double-page book spread and split into sub-sessions.
Must be called **after orientation** (step 1) and **before deskew** (step 2).
Each sub-session receives the raw page region and goes through the full
pipeline (deskew -> dewarp -> crop -> columns -> rows -> words -> grid)
independently, so each page gets its own deskew correction.
Returns ``{"multi_page": false}`` if only one page is detected.
"""
cached = await ensure_cached(session_id)
# Use oriented (preferred), fall back to original
img_bgr = next(
(v for k in ("oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for page-split detection")
t0 = time.time()
page_splits = detect_page_splits(img_bgr)
used_original = False
if not page_splits or len(page_splits) < 2:
# Orientation may have rotated a landscape double-page spread to
# portrait. Try the original (pre-orientation) image as fallback.
orig_bgr = cached.get("original_bgr")
if orig_bgr is not None and orig_bgr is not img_bgr:
page_splits_orig = detect_page_splits(orig_bgr)
if page_splits_orig and len(page_splits_orig) >= 2:
logger.info(
"OCR Pipeline: page-split session %s: spread detected on "
"ORIGINAL (orientation rotated it away)",
session_id,
)
img_bgr = orig_bgr
page_splits = page_splits_orig
used_original = True
if not page_splits or len(page_splits) < 2:
duration = time.time() - t0
logger.info(
"OCR Pipeline: page-split session %s: single page (%.2fs)",
session_id, duration,
)
return {
"session_id": session_id,
"multi_page": False,
"duration_seconds": round(duration, 2),
}
# Multi-page spread detected — create sub-sessions for full pipeline.
# start_step=2 means "ready for deskew" (orientation already applied).
# start_step=1 means "needs orientation too" (split from original image).
start_step = 1 if used_original else 2
sub_sessions = await create_page_sub_sessions_full(
session_id, cached, img_bgr, page_splits, start_step=start_step,
)
duration = time.time() - t0
split_info: Dict[str, Any] = {
"multi_page": True,
"page_count": len(page_splits),
"page_splits": page_splits,
"used_original": used_original,
"duration_seconds": round(duration, 2),
}
# Mark parent session as split and hidden from session list
await update_session_db(session_id, crop_result=split_info, status='split')
cached["crop_result"] = split_info
await append_pipeline_log(session_id, "page_split", {
"multi_page": True,
"page_count": len(page_splits),
}, duration_ms=int(duration * 1000))
logger.info(
"OCR Pipeline: page-split session %s: %d pages detected in %.2fs",
session_id, len(page_splits), duration,
)
h, w = img_bgr.shape[:2]
return {
"session_id": session_id,
**split_info,
"image_width": w,
"image_height": h,
"sub_sessions": sub_sessions,
}
+9 -687
View File
@@ -1,694 +1,16 @@
"""
Orientation & Crop API - Steps 1 and 4 of the OCR Pipeline.
Step 1: Orientation detection (fix 90/180/270 degree rotations)
Step 4 (UI index 3): Page cropping (after deskew + dewarp, so the image is straight)
These endpoints were extracted from the main pipeline to keep files manageable.
Barrel re-export: merges routers from orientation_api and crop_api,
and re-exports set_cache_ref for main.py.
"""
import logging
import time
import uuid as uuid_mod
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
import cv2
import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from orientation_crop_helpers import set_cache_ref # noqa: F401
from orientation_api import router as _orientation_router
from crop_api import router as _crop_router
from cv_vocab_pipeline import detect_and_fix_orientation
from page_crop import detect_and_crop_page, detect_page_splits
from ocr_pipeline_session_store import (
create_session_db,
get_session_db,
get_session_image,
get_sub_sessions,
update_session_db,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
# Reference to the shared cache from ocr_pipeline_api (set in main.py)
_cache: Dict[str, Dict[str, Any]] = {}
def set_cache_ref(cache: Dict[str, Dict[str, Any]]):
"""Set reference to the shared cache from ocr_pipeline_api."""
global _cache
_cache = cache
async def _ensure_cached(session_id: str) -> Dict[str, Any]:
"""Ensure session is in cache, loading from DB if needed."""
if session_id in _cache:
return _cache[session_id]
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
cache_entry: Dict[str, Any] = {
"id": session_id,
**session,
"original_bgr": None,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
}
for img_type, bgr_key in [
("original", "original_bgr"),
("oriented", "oriented_bgr"),
("cropped", "cropped_bgr"),
("deskewed", "deskewed_bgr"),
("dewarped", "dewarped_bgr"),
]:
png_data = await get_session_image(session_id, img_type)
if png_data:
arr = np.frombuffer(png_data, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
cache_entry[bgr_key] = bgr
_cache[session_id] = cache_entry
return cache_entry
async def _append_pipeline_log(session_id: str, step: str, metrics: dict, duration_ms: int):
"""Append a step entry to the pipeline log."""
from datetime import datetime
session = await get_session_db(session_id)
if not session:
return
pipeline_log = session.get("pipeline_log") or {"steps": []}
pipeline_log["steps"].append({
"step": step,
"completed_at": datetime.utcnow().isoformat(),
"success": True,
"duration_ms": duration_ms,
"metrics": metrics,
})
await update_session_db(session_id, pipeline_log=pipeline_log)
# ---------------------------------------------------------------------------
# Step 1: Orientation
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/orientation")
async def detect_orientation(session_id: str):
"""Detect and fix 90/180/270 degree rotations from scanners.
Reads the original image, applies orientation correction,
stores the result as oriented_png.
"""
cached = await _ensure_cached(session_id)
img_bgr = cached.get("original_bgr")
if img_bgr is None:
raise HTTPException(status_code=400, detail="Original image not available")
t0 = time.time()
# Detect and fix orientation
oriented_bgr, orientation_deg = detect_and_fix_orientation(img_bgr.copy())
duration = time.time() - t0
orientation_result = {
"orientation_degrees": orientation_deg,
"corrected": orientation_deg != 0,
"duration_seconds": round(duration, 2),
}
# Encode oriented image
success, png_buf = cv2.imencode(".png", oriented_bgr)
oriented_png = png_buf.tobytes() if success else b""
# Update cache
cached["oriented_bgr"] = oriented_bgr
cached["orientation_result"] = orientation_result
# Persist to DB
await update_session_db(
session_id,
oriented_png=oriented_png,
orientation_result=orientation_result,
current_step=2,
)
logger.info(
"OCR Pipeline: orientation session %s: %d° (%s) in %.2fs",
session_id, orientation_deg,
"corrected" if orientation_deg else "no change",
duration,
)
await _append_pipeline_log(session_id, "orientation", {
"orientation_degrees": orientation_deg,
"corrected": orientation_deg != 0,
}, duration_ms=int(duration * 1000))
h, w = oriented_bgr.shape[:2]
return {
"session_id": session_id,
**orientation_result,
"image_width": w,
"image_height": h,
"oriented_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/oriented",
}
# ---------------------------------------------------------------------------
# Step 1b: Page-split detection — runs AFTER orientation, BEFORE deskew
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/page-split")
async def detect_page_split(session_id: str):
"""Detect if the image is a double-page book spread and split into sub-sessions.
Must be called **after orientation** (step 1) and **before deskew** (step 2).
Each sub-session receives the raw page region and goes through the full
pipeline (deskew → dewarp → crop → columns → rows → words → grid)
independently, so each page gets its own deskew correction.
Returns ``{"multi_page": false}`` if only one page is detected.
"""
cached = await _ensure_cached(session_id)
# Use oriented (preferred), fall back to original
img_bgr = next(
(v for k in ("oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for page-split detection")
t0 = time.time()
page_splits = detect_page_splits(img_bgr)
used_original = False
if not page_splits or len(page_splits) < 2:
# Orientation may have rotated a landscape double-page spread to
# portrait. Try the original (pre-orientation) image as fallback.
orig_bgr = cached.get("original_bgr")
if orig_bgr is not None and orig_bgr is not img_bgr:
page_splits_orig = detect_page_splits(orig_bgr)
if page_splits_orig and len(page_splits_orig) >= 2:
logger.info(
"OCR Pipeline: page-split session %s: spread detected on "
"ORIGINAL (orientation rotated it away)",
session_id,
)
img_bgr = orig_bgr
page_splits = page_splits_orig
used_original = True
if not page_splits or len(page_splits) < 2:
duration = time.time() - t0
logger.info(
"OCR Pipeline: page-split session %s: single page (%.2fs)",
session_id, duration,
)
return {
"session_id": session_id,
"multi_page": False,
"duration_seconds": round(duration, 2),
}
# Multi-page spread detected — create sub-sessions for full pipeline.
# start_step=2 means "ready for deskew" (orientation already applied).
# start_step=1 means "needs orientation too" (split from original image).
start_step = 1 if used_original else 2
sub_sessions = await _create_page_sub_sessions_full(
session_id, cached, img_bgr, page_splits, start_step=start_step,
)
duration = time.time() - t0
split_info: Dict[str, Any] = {
"multi_page": True,
"page_count": len(page_splits),
"page_splits": page_splits,
"used_original": used_original,
"duration_seconds": round(duration, 2),
}
# Mark parent session as split and hidden from session list
await update_session_db(session_id, crop_result=split_info, status='split')
cached["crop_result"] = split_info
await _append_pipeline_log(session_id, "page_split", {
"multi_page": True,
"page_count": len(page_splits),
}, duration_ms=int(duration * 1000))
logger.info(
"OCR Pipeline: page-split session %s: %d pages detected in %.2fs",
session_id, len(page_splits), duration,
)
h, w = img_bgr.shape[:2]
return {
"session_id": session_id,
**split_info,
"image_width": w,
"image_height": h,
"sub_sessions": sub_sessions,
}
# ---------------------------------------------------------------------------
# Step 4 (UI index 3): Crop — runs after deskew + dewarp
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/crop")
async def auto_crop(session_id: str):
"""Auto-detect and crop scanner/book borders.
Reads the dewarped image (post-deskew + dewarp, so the page is straight).
Falls back to oriented → original if earlier steps were skipped.
If the image is a multi-page spread (e.g. book on scanner), it will
automatically split into separate sub-sessions per page, crop each
individually, and return the split info.
"""
cached = await _ensure_cached(session_id)
# Use dewarped (preferred), fall back to oriented, then original
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for cropping")
t0 = time.time()
# --- Check for existing sub-sessions (from page-split step) ---
# If page-split already created sub-sessions, skip multi-page detection
# in the crop step. Each sub-session runs its own crop independently.
existing_subs = await get_sub_sessions(session_id)
if existing_subs:
crop_result = cached.get("crop_result") or {}
if crop_result.get("multi_page"):
# Already split — just return the existing info
duration = time.time() - t0
h, w = img_bgr.shape[:2]
return {
"session_id": session_id,
**crop_result,
"image_width": w,
"image_height": h,
"sub_sessions": [
{"id": s["id"], "name": s.get("name"), "page_index": s.get("box_index", i)}
for i, s in enumerate(existing_subs)
],
"note": "Page split was already performed; each sub-session runs its own crop.",
}
# --- Multi-page detection (fallback for sessions that skipped page-split) ---
page_splits = detect_page_splits(img_bgr)
if page_splits and len(page_splits) >= 2:
# Multi-page spread detected — create sub-sessions
sub_sessions = await _create_page_sub_sessions(
session_id, cached, img_bgr, page_splits,
)
duration = time.time() - t0
crop_info: Dict[str, Any] = {
"crop_applied": True,
"multi_page": True,
"page_count": len(page_splits),
"page_splits": page_splits,
"duration_seconds": round(duration, 2),
}
cached["crop_result"] = crop_info
# Store the first page as the main cropped image for backward compat
first_page = page_splits[0]
first_bgr = img_bgr[
first_page["y"]:first_page["y"] + first_page["height"],
first_page["x"]:first_page["x"] + first_page["width"],
].copy()
first_cropped, _ = detect_and_crop_page(first_bgr)
cached["cropped_bgr"] = first_cropped
ok, png_buf = cv2.imencode(".png", first_cropped)
await update_session_db(
session_id,
cropped_png=png_buf.tobytes() if ok else b"",
crop_result=crop_info,
current_step=5,
status='split',
)
logger.info(
"OCR Pipeline: crop session %s: multi-page split into %d pages in %.2fs",
session_id, len(page_splits), duration,
)
await _append_pipeline_log(session_id, "crop", {
"multi_page": True,
"page_count": len(page_splits),
}, duration_ms=int(duration * 1000))
h, w = first_cropped.shape[:2]
return {
"session_id": session_id,
**crop_info,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
"sub_sessions": sub_sessions,
}
# --- Single page (normal) ---
cropped_bgr, crop_info = detect_and_crop_page(img_bgr)
duration = time.time() - t0
crop_info["duration_seconds"] = round(duration, 2)
crop_info["multi_page"] = False
# Encode cropped image
success, png_buf = cv2.imencode(".png", cropped_bgr)
cropped_png = png_buf.tobytes() if success else b""
# Update cache
cached["cropped_bgr"] = cropped_bgr
cached["crop_result"] = crop_info
# Persist to DB
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_info,
current_step=5,
)
logger.info(
"OCR Pipeline: crop session %s: applied=%s format=%s in %.2fs",
session_id, crop_info["crop_applied"],
crop_info.get("detected_format", "?"),
duration,
)
await _append_pipeline_log(session_id, "crop", {
"crop_applied": crop_info["crop_applied"],
"detected_format": crop_info.get("detected_format"),
"format_confidence": crop_info.get("format_confidence"),
}, duration_ms=int(duration * 1000))
h, w = cropped_bgr.shape[:2]
return {
"session_id": session_id,
**crop_info,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
async def _create_page_sub_sessions(
parent_session_id: str,
parent_cached: dict,
full_img_bgr: np.ndarray,
page_splits: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Create sub-sessions for each detected page in a multi-page spread.
Each page region is individually cropped, then stored as a sub-session
with its own cropped image ready for the rest of the pipeline.
"""
# Check for existing sub-sessions (idempotent)
existing = await get_sub_sessions(parent_session_id)
if existing:
return [
{"id": s["id"], "name": s["name"], "page_index": s.get("box_index", i)}
for i, s in enumerate(existing)
]
parent_name = parent_cached.get("name", "Scan")
parent_filename = parent_cached.get("filename", "scan.png")
sub_sessions: List[Dict[str, Any]] = []
for page in page_splits:
pi = page["page_index"]
px, py = page["x"], page["y"]
pw, ph = page["width"], page["height"]
# Extract page region
page_bgr = full_img_bgr[py:py + ph, px:px + pw].copy()
# Crop each page individually (remove its own borders)
cropped_page, page_crop_info = detect_and_crop_page(page_bgr)
# Encode as PNG
ok, png_buf = cv2.imencode(".png", cropped_page)
page_png = png_buf.tobytes() if ok else b""
sub_id = str(uuid_mod.uuid4())
sub_name = f"{parent_name} — Seite {pi + 1}"
await create_session_db(
session_id=sub_id,
name=sub_name,
filename=parent_filename,
original_png=page_png,
)
# Pre-populate: set cropped = original (already cropped)
await update_session_db(
sub_id,
cropped_png=page_png,
crop_result=page_crop_info,
current_step=5,
)
ch, cw = cropped_page.shape[:2]
sub_sessions.append({
"id": sub_id,
"name": sub_name,
"page_index": pi,
"source_rect": page,
"cropped_size": {"width": cw, "height": ch},
"detected_format": page_crop_info.get("detected_format"),
})
logger.info(
"Page sub-session %s: page %d, region x=%d w=%d -> cropped %dx%d",
sub_id, pi + 1, px, pw, cw, ch,
)
return sub_sessions
async def _create_page_sub_sessions_full(
parent_session_id: str,
parent_cached: dict,
full_img_bgr: np.ndarray,
page_splits: List[Dict[str, Any]],
start_step: int = 2,
) -> List[Dict[str, Any]]:
"""Create sub-sessions for each page with RAW regions for full pipeline processing.
Unlike ``_create_page_sub_sessions`` (used by the crop step), these
sub-sessions store the *uncropped* page region and start at
``start_step`` (default 2 = ready for deskew; 1 if orientation still
needed). Each page goes through its own pipeline independently,
which is essential for book spreads where each page has a different tilt.
"""
# Idempotent: reuse existing sub-sessions
existing = await get_sub_sessions(parent_session_id)
if existing:
return [
{"id": s["id"], "name": s["name"], "page_index": s.get("box_index", i)}
for i, s in enumerate(existing)
]
parent_name = parent_cached.get("name", "Scan")
parent_filename = parent_cached.get("filename", "scan.png")
sub_sessions: List[Dict[str, Any]] = []
for page in page_splits:
pi = page["page_index"]
px, py = page["x"], page["y"]
pw, ph = page["width"], page["height"]
# Extract RAW page region — NO individual cropping here; each
# sub-session will run its own crop step after deskew + dewarp.
page_bgr = full_img_bgr[py:py + ph, px:px + pw].copy()
# Encode as PNG
ok, png_buf = cv2.imencode(".png", page_bgr)
page_png = png_buf.tobytes() if ok else b""
sub_id = str(uuid_mod.uuid4())
sub_name = f"{parent_name} — Seite {pi + 1}"
await create_session_db(
session_id=sub_id,
name=sub_name,
filename=parent_filename,
original_png=page_png,
)
# start_step=2 → ready for deskew (orientation already done on spread)
# start_step=1 → needs its own orientation (split from original image)
await update_session_db(sub_id, current_step=start_step)
# Cache the BGR so the pipeline can start immediately
_cache[sub_id] = {
"id": sub_id,
"filename": parent_filename,
"name": sub_name,
"original_bgr": page_bgr,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
"orientation_result": None,
"crop_result": None,
"deskew_result": None,
"dewarp_result": None,
"ground_truth": {},
"current_step": start_step,
}
rh, rw = page_bgr.shape[:2]
sub_sessions.append({
"id": sub_id,
"name": sub_name,
"page_index": pi,
"source_rect": page,
"image_size": {"width": rw, "height": rh},
})
logger.info(
"Page sub-session %s (full pipeline): page %d, region x=%d w=%d%dx%d",
sub_id, pi + 1, px, pw, rw, rh,
)
return sub_sessions
class ManualCropRequest(BaseModel):
x: float # percentage 0-100
y: float # percentage 0-100
width: float # percentage 0-100
height: float # percentage 0-100
@router.post("/sessions/{session_id}/crop/manual")
async def manual_crop(session_id: str, req: ManualCropRequest):
"""Manually crop using percentage coordinates."""
cached = await _ensure_cached(session_id)
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available for cropping")
h, w = img_bgr.shape[:2]
# Convert percentages to pixels
px_x = int(w * req.x / 100.0)
px_y = int(h * req.y / 100.0)
px_w = int(w * req.width / 100.0)
px_h = int(h * req.height / 100.0)
# Clamp
px_x = max(0, min(px_x, w - 1))
px_y = max(0, min(px_y, h - 1))
px_w = max(1, min(px_w, w - px_x))
px_h = max(1, min(px_h, h - px_y))
cropped_bgr = img_bgr[px_y:px_y + px_h, px_x:px_x + px_w].copy()
success, png_buf = cv2.imencode(".png", cropped_bgr)
cropped_png = png_buf.tobytes() if success else b""
crop_result = {
"crop_applied": True,
"crop_rect": {"x": px_x, "y": px_y, "width": px_w, "height": px_h},
"crop_rect_pct": {"x": round(req.x, 2), "y": round(req.y, 2),
"width": round(req.width, 2), "height": round(req.height, 2)},
"original_size": {"width": w, "height": h},
"cropped_size": {"width": px_w, "height": px_h},
"method": "manual",
}
cached["cropped_bgr"] = cropped_bgr
cached["crop_result"] = crop_result
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_result,
current_step=5,
)
ch, cw = cropped_bgr.shape[:2]
return {
"session_id": session_id,
**crop_result,
"image_width": cw,
"image_height": ch,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
@router.post("/sessions/{session_id}/crop/skip")
async def skip_crop(session_id: str):
"""Skip cropping — use dewarped (or oriented/original) image as-is."""
cached = await _ensure_cached(session_id)
img_bgr = next(
(v for k in ("dewarped_bgr", "oriented_bgr", "original_bgr")
if (v := cached.get(k)) is not None),
None,
)
if img_bgr is None:
raise HTTPException(status_code=400, detail="No image available")
h, w = img_bgr.shape[:2]
# Store the dewarped image as cropped (identity crop)
success, png_buf = cv2.imencode(".png", img_bgr)
cropped_png = png_buf.tobytes() if success else b""
crop_result = {
"crop_applied": False,
"skipped": True,
"original_size": {"width": w, "height": h},
"cropped_size": {"width": w, "height": h},
}
cached["cropped_bgr"] = img_bgr
cached["crop_result"] = crop_result
await update_session_db(
session_id,
cropped_png=cropped_png,
crop_result=crop_result,
current_step=5,
)
return {
"session_id": session_id,
**crop_result,
"image_width": w,
"image_height": h,
"cropped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/cropped",
}
router = APIRouter()
router.include_router(_orientation_router)
router.include_router(_crop_router)
@@ -0,0 +1,86 @@
"""
Orientation & Crop shared helpers - cache management and pipeline logging.
"""
import logging
from typing import Any, Dict
import cv2
import numpy as np
from fastapi import HTTPException
from ocr_pipeline_session_store import (
get_session_db,
get_session_image,
update_session_db,
)
logger = logging.getLogger(__name__)
# Reference to the shared cache from ocr_pipeline_api (set in main.py)
_cache: Dict[str, Dict[str, Any]] = {}
def set_cache_ref(cache: Dict[str, Dict[str, Any]]):
"""Set reference to the shared cache from ocr_pipeline_api."""
global _cache
_cache = cache
def get_cache_ref() -> Dict[str, Dict[str, Any]]:
"""Get reference to the shared cache."""
return _cache
async def ensure_cached(session_id: str) -> Dict[str, Any]:
"""Ensure session is in cache, loading from DB if needed."""
if session_id in _cache:
return _cache[session_id]
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
cache_entry: Dict[str, Any] = {
"id": session_id,
**session,
"original_bgr": None,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
}
for img_type, bgr_key in [
("original", "original_bgr"),
("oriented", "oriented_bgr"),
("cropped", "cropped_bgr"),
("deskewed", "deskewed_bgr"),
("dewarped", "dewarped_bgr"),
]:
png_data = await get_session_image(session_id, img_type)
if png_data:
arr = np.frombuffer(png_data, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
cache_entry[bgr_key] = bgr
_cache[session_id] = cache_entry
return cache_entry
async def append_pipeline_log(session_id: str, step: str, metrics: dict, duration_ms: int):
"""Append a step entry to the pipeline log."""
from datetime import datetime
session = await get_session_db(session_id)
if not session:
return
pipeline_log = session.get("pipeline_log") or {"steps": []}
pipeline_log["steps"].append({
"step": step,
"completed_at": datetime.utcnow().isoformat(),
"success": True,
"duration_ms": duration_ms,
"metrics": metrics,
})
await update_session_db(session_id, pipeline_log=pipeline_log)
@@ -0,0 +1,189 @@
"""
Sub-session creation for multi-page spreads.
Used by both the page-split and crop steps when a double-page scan is detected.
"""
import logging
import uuid as uuid_mod
from typing import Any, Dict, List
import cv2
import numpy as np
from page_crop import detect_and_crop_page
from ocr_pipeline_session_store import (
create_session_db,
get_sub_sessions,
update_session_db,
)
from orientation_crop_helpers import get_cache_ref
logger = logging.getLogger(__name__)
async def create_page_sub_sessions(
parent_session_id: str,
parent_cached: dict,
full_img_bgr: np.ndarray,
page_splits: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Create sub-sessions for each detected page in a multi-page spread.
Each page region is individually cropped, then stored as a sub-session
with its own cropped image ready for the rest of the pipeline.
"""
# Check for existing sub-sessions (idempotent)
existing = await get_sub_sessions(parent_session_id)
if existing:
return [
{"id": s["id"], "name": s["name"], "page_index": s.get("box_index", i)}
for i, s in enumerate(existing)
]
parent_name = parent_cached.get("name", "Scan")
parent_filename = parent_cached.get("filename", "scan.png")
sub_sessions: List[Dict[str, Any]] = []
for page in page_splits:
pi = page["page_index"]
px, py = page["x"], page["y"]
pw, ph = page["width"], page["height"]
# Extract page region
page_bgr = full_img_bgr[py:py + ph, px:px + pw].copy()
# Crop each page individually (remove its own borders)
cropped_page, page_crop_info = detect_and_crop_page(page_bgr)
# Encode as PNG
ok, png_buf = cv2.imencode(".png", cropped_page)
page_png = png_buf.tobytes() if ok else b""
sub_id = str(uuid_mod.uuid4())
sub_name = f"{parent_name} — Seite {pi + 1}"
await create_session_db(
session_id=sub_id,
name=sub_name,
filename=parent_filename,
original_png=page_png,
)
# Pre-populate: set cropped = original (already cropped)
await update_session_db(
sub_id,
cropped_png=page_png,
crop_result=page_crop_info,
current_step=5,
)
ch, cw = cropped_page.shape[:2]
sub_sessions.append({
"id": sub_id,
"name": sub_name,
"page_index": pi,
"source_rect": page,
"cropped_size": {"width": cw, "height": ch},
"detected_format": page_crop_info.get("detected_format"),
})
logger.info(
"Page sub-session %s: page %d, region x=%d w=%d -> cropped %dx%d",
sub_id, pi + 1, px, pw, cw, ch,
)
return sub_sessions
async def create_page_sub_sessions_full(
parent_session_id: str,
parent_cached: dict,
full_img_bgr: np.ndarray,
page_splits: List[Dict[str, Any]],
start_step: int = 2,
) -> List[Dict[str, Any]]:
"""Create sub-sessions for each page with RAW regions for full pipeline processing.
Unlike ``create_page_sub_sessions`` (used by the crop step), these
sub-sessions store the *uncropped* page region and start at
``start_step`` (default 2 = ready for deskew; 1 if orientation still
needed). Each page goes through its own pipeline independently,
which is essential for book spreads where each page has a different tilt.
"""
_cache = get_cache_ref()
# Idempotent: reuse existing sub-sessions
existing = await get_sub_sessions(parent_session_id)
if existing:
return [
{"id": s["id"], "name": s["name"], "page_index": s.get("box_index", i)}
for i, s in enumerate(existing)
]
parent_name = parent_cached.get("name", "Scan")
parent_filename = parent_cached.get("filename", "scan.png")
sub_sessions: List[Dict[str, Any]] = []
for page in page_splits:
pi = page["page_index"]
px, py = page["x"], page["y"]
pw, ph = page["width"], page["height"]
# Extract RAW page region — NO individual cropping here; each
# sub-session will run its own crop step after deskew + dewarp.
page_bgr = full_img_bgr[py:py + ph, px:px + pw].copy()
# Encode as PNG
ok, png_buf = cv2.imencode(".png", page_bgr)
page_png = png_buf.tobytes() if ok else b""
sub_id = str(uuid_mod.uuid4())
sub_name = f"{parent_name} — Seite {pi + 1}"
await create_session_db(
session_id=sub_id,
name=sub_name,
filename=parent_filename,
original_png=page_png,
)
# start_step=2 -> ready for deskew (orientation already done on spread)
# start_step=1 -> needs its own orientation (split from original image)
await update_session_db(sub_id, current_step=start_step)
# Cache the BGR so the pipeline can start immediately
_cache[sub_id] = {
"id": sub_id,
"filename": parent_filename,
"name": sub_name,
"original_bgr": page_bgr,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
"orientation_result": None,
"crop_result": None,
"deskew_result": None,
"dewarp_result": None,
"ground_truth": {},
"current_step": start_step,
}
rh, rw = page_bgr.shape[:2]
sub_sessions.append({
"id": sub_id,
"name": sub_name,
"page_index": pi,
"source_rect": page,
"image_size": {"width": rw, "height": rh},
})
logger.info(
"Page sub-session %s (full pipeline): page %d, region x=%d w=%d -> %dx%d",
sub_id, pi + 1, px, pw, rw, rh,
)
return sub_sessions
+10 -670
View File
@@ -1,677 +1,17 @@
"""
PDF Export Module for Abiturkorrektur System
Generates:
- Individual Gutachten PDFs for each student
- Klausur overview PDFs with grade distribution
- Niedersachsen-compliant formatting
Barrel re-export: all PDF generation functions and constants.
"""
import io
from datetime import datetime
from typing import Dict, List, Optional, Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, Image, KeepTogether
from pdf_export_styles import ( # noqa: F401
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
CRITERIA_WEIGHTS,
get_custom_styles,
)
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# =============================================
# CONSTANTS
# =============================================
GRADE_POINTS_TO_NOTE = {
15: "1+", 14: "1", 13: "1-",
12: "2+", 11: "2", 10: "2-",
9: "3+", 8: "3", 7: "3-",
6: "4+", 5: "4", 4: "4-",
3: "5+", 2: "5", 1: "5-",
0: "6"
}
CRITERIA_DISPLAY_NAMES = {
"rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)",
"grammatik": "Sprachliche Richtigkeit (Grammatik)",
"inhalt": "Inhaltliche Leistung",
"struktur": "Aufbau und Struktur",
"stil": "Ausdruck und Stil"
}
CRITERIA_WEIGHTS = {
"rechtschreibung": 15,
"grammatik": 15,
"inhalt": 40,
"struktur": 15,
"stil": 15
}
# =============================================
# STYLES
# =============================================
def get_custom_styles():
"""Create custom paragraph styles for Gutachten."""
styles = getSampleStyleSheet()
# Title style
styles.add(ParagraphStyle(
name='GutachtenTitle',
parent=styles['Heading1'],
fontSize=16,
spaceAfter=12,
alignment=TA_CENTER,
textColor=colors.HexColor('#1e3a5f')
))
# Subtitle style
styles.add(ParagraphStyle(
name='GutachtenSubtitle',
parent=styles['Heading2'],
fontSize=12,
spaceAfter=8,
spaceBefore=16,
textColor=colors.HexColor('#2c5282')
))
# Section header
styles.add(ParagraphStyle(
name='SectionHeader',
parent=styles['Heading3'],
fontSize=11,
spaceAfter=6,
spaceBefore=12,
textColor=colors.HexColor('#2d3748'),
borderColor=colors.HexColor('#e2e8f0'),
borderWidth=0,
borderPadding=0
))
# Body text
styles.add(ParagraphStyle(
name='GutachtenBody',
parent=styles['Normal'],
fontSize=10,
leading=14,
alignment=TA_JUSTIFY,
spaceAfter=6
))
# Small text for footer/meta
styles.add(ParagraphStyle(
name='MetaText',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_LEFT
))
# List item
styles.add(ParagraphStyle(
name='ListItem',
parent=styles['Normal'],
fontSize=10,
leftIndent=20,
bulletIndent=10,
spaceAfter=4
))
return styles
# =============================================
# PDF GENERATION FUNCTIONS
# =============================================
def generate_gutachten_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]] = None,
workflow_data: Dict[str, Any] = None
) -> bytes:
"""
Generate a PDF Gutachten for a single student.
Args:
student_data: Student work data including criteria_scores, gutachten, grade_points
klausur_data: Klausur metadata (title, subject, year, etc.)
annotations: List of annotations for annotation summary
workflow_data: Examiner workflow data (EK, ZK, DK info)
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
from pdf_export_gutachten import generate_gutachten_pdf # noqa: F401
from pdf_export_overview import ( # noqa: F401
generate_klausur_overview_pdf,
generate_annotations_pdf,
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Gutachten zur Abiturklausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information table
meta_data = [
["Pruefling:", student_data.get('student_name', 'Anonym')],
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Datum:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Gutachten content
gutachten = student_data.get('gutachten', {})
if gutachten:
# Einleitung
if gutachten.get('einleitung'):
story.append(Paragraph("Einleitung", styles['SectionHeader']))
story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
# Hauptteil
if gutachten.get('hauptteil'):
story.append(Paragraph("Hauptteil", styles['SectionHeader']))
story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
# Fazit
if gutachten.get('fazit'):
story.append(Paragraph("Fazit", styles['SectionHeader']))
story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
# Staerken und Schwaechen
if gutachten.get('staerken') or gutachten.get('schwaechen'):
story.append(Spacer(1, 0.3*cm))
if gutachten.get('staerken'):
story.append(Paragraph("Staerken:", styles['SectionHeader']))
for s in gutachten['staerken']:
story.append(Paragraph(f"{s}", styles['ListItem']))
if gutachten.get('schwaechen'):
story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader']))
for s in gutachten['schwaechen']:
story.append(Paragraph(f"{s}", styles['ListItem']))
else:
story.append(Paragraph("<i>Kein Gutachten-Text vorhanden.</i>", styles['GutachtenBody']))
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Bewertungstabelle
story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
criteria_scores = student_data.get('criteria_scores', {})
# Build criteria table data
table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]]
total_weighted = 0
total_weight = 0
for key, display_name in CRITERIA_DISPLAY_NAMES.items():
weight = CRITERIA_WEIGHTS.get(key, 0)
score_data = criteria_scores.get(key, {})
score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data
# Calculate weighted contribution
weighted_score = (score / 100) * weight if score else 0
total_weighted += weighted_score
total_weight += weight
table_data.append([
display_name,
f"{weight}%",
f"{score}%",
f"{weighted_score:.1f}"
])
# Add total row
table_data.append([
"Gesamt",
f"{total_weight}%",
"",
f"{total_weighted:.1f}"
])
criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm])
criteria_table.setStyle(TableStyle([
# Header row
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
# Body rows
('FONTSIZE', (0, 1), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
# Grid
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
# Total row
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
# Alternating row colors
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(criteria_table)
story.append(Spacer(1, 0.5*cm))
# Final grade box
grade_points = student_data.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?")
raw_points = student_data.get('raw_points', 0)
grade_data = [
["Rohpunkte:", f"{raw_points} / 100"],
["Notenpunkte:", f"{grade_points} Punkte"],
["Note:", grade_note]
]
grade_table = Table(grade_data, colWidths=[4*cm, 4*cm])
grade_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('FONTSIZE', (1, -1), (1, -1), 14),
('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 12),
('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
]))
story.append(KeepTogether([
Paragraph("Endergebnis", styles['SectionHeader']),
Spacer(1, 0.2*cm),
grade_table
]))
# Examiner workflow information
if workflow_data:
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Korrekturverlauf", styles['SectionHeader']))
workflow_rows = []
if workflow_data.get('erst_korrektor'):
ek = workflow_data['erst_korrektor']
workflow_rows.append([
"Erstkorrektor:",
ek.get('name', 'Unbekannt'),
f"{ek.get('grade_points', '-')} Punkte"
])
if workflow_data.get('zweit_korrektor'):
zk = workflow_data['zweit_korrektor']
workflow_rows.append([
"Zweitkorrektor:",
zk.get('name', 'Unbekannt'),
f"{zk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('dritt_korrektor'):
dk = workflow_data['dritt_korrektor']
workflow_rows.append([
"Drittkorrektor:",
dk.get('name', 'Unbekannt'),
f"{dk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('final_grade_source'):
workflow_rows.append([
"Endnote durch:",
workflow_data['final_grade_source'],
""
])
if workflow_rows:
workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm])
workflow_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(workflow_table)
# Annotation summary (if any)
if annotations:
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader']))
# Group annotations by type
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)} Anmerkungen)", styles['ListItem']))
# Footer with generation info
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def generate_klausur_overview_pdf(
klausur_data: Dict[str, Any],
students: List[Dict[str, Any]],
fairness_data: Optional[Dict[str, Any]] = None
) -> bytes:
"""
Generate an overview PDF for an entire Klausur with all student grades.
Args:
klausur_data: Klausur metadata
students: List of all student work data
fairness_data: Optional fairness analysis data
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=1.5*cm,
leftMargin=1.5*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information
meta_data = [
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Anzahl Arbeiten:", str(len(students))],
["Stand:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
# Statistics (if fairness data available)
if fairness_data and fairness_data.get('statistics'):
stats = fairness_data['statistics']
story.append(Paragraph("Statistik", styles['SectionHeader']))
stats_data = [
["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"],
["Minimum:", f"{stats.get('min_grade', 0)} Punkte"],
["Maximum:", f"{stats.get('max_grade', 0)} Punkte"],
["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"],
]
stats_table = Table(stats_data, colWidths=[4*cm, 4*cm])
stats_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')),
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(stats_table)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Student grades table
story.append(Paragraph("Einzelergebnisse", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
# Sort students by grade (descending)
sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True)
# Build table header
table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]]
for idx, student in enumerate(sorted_students, 1):
grade_points = student.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-")
raw_points = student.get('raw_points', 0)
status = student.get('status', 'unknown')
# Format status
status_display = {
'completed': 'Abgeschlossen',
'first_examiner': 'In Korrektur',
'second_examiner': 'Zweitkorrektur',
'uploaded': 'Hochgeladen',
'ocr_complete': 'OCR fertig',
'analyzing': 'Wird analysiert'
}.get(status, status)
table_data.append([
str(idx),
student.get('student_name', 'Anonym'),
f"{raw_points}/100",
str(grade_points),
grade_note,
status_display
])
# Create table
student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm])
student_table.setStyle(TableStyle([
# Header
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
# Body
('FONTSIZE', (0, 1), (-1, -1), 9),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (2, 1), (4, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
# Grid
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
# Alternating rows
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(student_table)
# Grade distribution
story.append(Spacer(1, 0.5*cm))
story.append(Paragraph("Notenverteilung", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
# Count grades
grade_counts = {}
for student in sorted_students:
gp = student.get('grade_points', 0)
grade_counts[gp] = grade_counts.get(gp, 0) + 1
# Build grade distribution table
dist_data = [["Punkte", "Note", "Anzahl"]]
for points in range(15, -1, -1):
if points in grade_counts:
note = GRADE_POINTS_TO_NOTE.get(points, "-")
count = grade_counts[points]
dist_data.append([str(points), note, str(count)])
if len(dist_data) > 1:
dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm])
dist_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(dist_table)
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def generate_annotations_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]]
) -> bytes:
"""
Generate a PDF with all annotations for a student work.
Args:
student_data: Student work data
klausur_data: Klausur metadata
annotations: List of all annotations
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
if not annotations:
story.append(Paragraph("<i>Keine Anmerkungen vorhanden.</i>", styles['GutachtenBody']))
else:
# Group by type
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
# Sort by page then position
sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0)))
for idx, ann in enumerate(sorted_anns, 1):
page = ann.get('page', 1)
text = ann.get('text', '')
suggestion = ann.get('suggestion', '')
severity = ann.get('severity', 'minor')
# Build annotation text
ann_text = f"<b>[S.{page}]</b> {text}"
if suggestion:
ann_text += f" → <i>{suggestion}</i>"
# Color code by severity
if severity == 'critical':
ann_text = f"<font color='red'>{ann_text}</font>"
elif severity == 'major':
ann_text = f"<font color='orange'>{ann_text}</font>"
story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem']))
story.append(Spacer(1, 0.3*cm))
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
@@ -0,0 +1,315 @@
"""
PDF Export - Individual Gutachten PDF generation.
Generates a single student's Gutachten with criteria table,
workflow info, and annotation summary.
"""
import io
from datetime import datetime
from typing import Dict, List, Optional, Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether
)
from pdf_export_styles import (
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
CRITERIA_WEIGHTS,
get_custom_styles,
)
def generate_gutachten_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]] = None,
workflow_data: Dict[str, Any] = None
) -> bytes:
"""
Generate a PDF Gutachten for a single student.
Args:
student_data: Student work data including criteria_scores, gutachten, grade_points
klausur_data: Klausur metadata (title, subject, year, etc.)
annotations: List of annotations for annotation summary
workflow_data: Examiner workflow data (EK, ZK, DK info)
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Gutachten zur Abiturklausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information table
meta_data = [
["Pruefling:", student_data.get('student_name', 'Anonym')],
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Datum:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Gutachten content
_add_gutachten_content(story, styles, student_data)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Bewertungstabelle
_add_criteria_table(story, styles, student_data)
# Final grade box
_add_grade_box(story, styles, student_data)
# Examiner workflow information
if workflow_data:
_add_workflow_info(story, styles, workflow_data)
# Annotation summary
if annotations:
_add_annotation_summary(story, styles, annotations)
# Footer
_add_footer(story, styles)
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def _add_gutachten_content(story, styles, student_data):
"""Add gutachten text sections to the story."""
gutachten = student_data.get('gutachten', {})
if gutachten:
if gutachten.get('einleitung'):
story.append(Paragraph("Einleitung", styles['SectionHeader']))
story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('hauptteil'):
story.append(Paragraph("Hauptteil", styles['SectionHeader']))
story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('fazit'):
story.append(Paragraph("Fazit", styles['SectionHeader']))
story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody']))
story.append(Spacer(1, 0.3*cm))
if gutachten.get('staerken') or gutachten.get('schwaechen'):
story.append(Spacer(1, 0.3*cm))
if gutachten.get('staerken'):
story.append(Paragraph("Staerken:", styles['SectionHeader']))
for s in gutachten['staerken']:
story.append(Paragraph(f"{s}", styles['ListItem']))
if gutachten.get('schwaechen'):
story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader']))
for s in gutachten['schwaechen']:
story.append(Paragraph(f"{s}", styles['ListItem']))
else:
story.append(Paragraph("<i>Kein Gutachten-Text vorhanden.</i>", styles['GutachtenBody']))
def _add_criteria_table(story, styles, student_data):
"""Add criteria scoring table to the story."""
story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
criteria_scores = student_data.get('criteria_scores', {})
table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]]
total_weighted = 0
total_weight = 0
for key, display_name in CRITERIA_DISPLAY_NAMES.items():
weight = CRITERIA_WEIGHTS.get(key, 0)
score_data = criteria_scores.get(key, {})
score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data
weighted_score = (score / 100) * weight if score else 0
total_weighted += weighted_score
total_weight += weight
table_data.append([
display_name,
f"{weight}%",
f"{score}%",
f"{weighted_score:.1f}"
])
table_data.append([
"Gesamt",
f"{total_weight}%",
"",
f"{total_weighted:.1f}"
])
criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm])
criteria_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
('FONTSIZE', (0, 1), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')),
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(criteria_table)
story.append(Spacer(1, 0.5*cm))
def _add_grade_box(story, styles, student_data):
"""Add final grade box to the story."""
grade_points = student_data.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?")
raw_points = student_data.get('raw_points', 0)
grade_data = [
["Rohpunkte:", f"{raw_points} / 100"],
["Notenpunkte:", f"{grade_points} Punkte"],
["Note:", grade_note]
]
grade_table = Table(grade_data, colWidths=[4*cm, 4*cm])
grade_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('FONTSIZE', (1, -1), (1, -1), 14),
('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('LEFTPADDING', (0, 0), (-1, -1), 12),
('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
]))
story.append(KeepTogether([
Paragraph("Endergebnis", styles['SectionHeader']),
Spacer(1, 0.2*cm),
grade_table
]))
def _add_workflow_info(story, styles, workflow_data):
"""Add examiner workflow information to the story."""
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Korrekturverlauf", styles['SectionHeader']))
workflow_rows = []
if workflow_data.get('erst_korrektor'):
ek = workflow_data['erst_korrektor']
workflow_rows.append([
"Erstkorrektor:",
ek.get('name', 'Unbekannt'),
f"{ek.get('grade_points', '-')} Punkte"
])
if workflow_data.get('zweit_korrektor'):
zk = workflow_data['zweit_korrektor']
workflow_rows.append([
"Zweitkorrektor:",
zk.get('name', 'Unbekannt'),
f"{zk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('dritt_korrektor'):
dk = workflow_data['dritt_korrektor']
workflow_rows.append([
"Drittkorrektor:",
dk.get('name', 'Unbekannt'),
f"{dk.get('grade_points', '-')} Punkte"
])
if workflow_data.get('final_grade_source'):
workflow_rows.append([
"Endnote durch:",
workflow_data['final_grade_source'],
""
])
if workflow_rows:
workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm])
workflow_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(workflow_table)
def _add_annotation_summary(story, styles, annotations):
"""Add annotation summary to the story."""
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader']))
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)} Anmerkungen)", styles['ListItem']))
def _add_footer(story, styles):
"""Add generation footer to the story."""
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
@@ -0,0 +1,297 @@
"""
PDF Export - Klausur overview and annotations PDF generation.
Generates:
- Klausur overview with grade distribution for all students
- Annotations PDF for a single student
"""
import io
from datetime import datetime
from typing import Dict, List, Optional, Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable
)
from pdf_export_styles import (
GRADE_POINTS_TO_NOTE,
CRITERIA_DISPLAY_NAMES,
get_custom_styles,
)
def generate_klausur_overview_pdf(
klausur_data: Dict[str, Any],
students: List[Dict[str, Any]],
fairness_data: Optional[Dict[str, Any]] = None
) -> bytes:
"""
Generate an overview PDF for an entire Klausur with all student grades.
Args:
klausur_data: Klausur metadata
students: List of all student work data
fairness_data: Optional fairness analysis data
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=1.5*cm,
leftMargin=1.5*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle']))
story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
# Meta information
meta_data = [
["Schuljahr:", f"{klausur_data.get('year', 2025)}"],
["Kurs:", klausur_data.get('semester', 'Abitur')],
["Anzahl Arbeiten:", str(len(students))],
["Stand:", datetime.now().strftime("%d.%m.%Y")]
]
meta_table = Table(meta_data, colWidths=[4*cm, 10*cm])
meta_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
]))
story.append(meta_table)
story.append(Spacer(1, 0.5*cm))
# Statistics (if fairness data available)
if fairness_data and fairness_data.get('statistics'):
_add_statistics(story, styles, fairness_data['statistics'])
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0')))
story.append(Spacer(1, 0.5*cm))
# Student grades table
sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True)
_add_student_table(story, styles, sorted_students)
# Grade distribution
_add_grade_distribution(story, styles, sorted_students)
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def _add_statistics(story, styles, stats):
"""Add statistics section."""
story.append(Paragraph("Statistik", styles['SectionHeader']))
stats_data = [
["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"],
["Minimum:", f"{stats.get('min_grade', 0)} Punkte"],
["Maximum:", f"{stats.get('max_grade', 0)} Punkte"],
["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"],
]
stats_table = Table(stats_data, colWidths=[4*cm, 4*cm])
stats_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')),
('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(stats_table)
story.append(Spacer(1, 0.5*cm))
def _add_student_table(story, styles, sorted_students):
"""Add student grades table."""
story.append(Paragraph("Einzelergebnisse", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]]
for idx, student in enumerate(sorted_students, 1):
grade_points = student.get('grade_points', 0)
grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-")
raw_points = student.get('raw_points', 0)
status = student.get('status', 'unknown')
status_display = {
'completed': 'Abgeschlossen',
'first_examiner': 'In Korrektur',
'second_examiner': 'Zweitkorrektur',
'uploaded': 'Hochgeladen',
'ocr_complete': 'OCR fertig',
'analyzing': 'Wird analysiert'
}.get(status, status)
table_data.append([
str(idx),
student.get('student_name', 'Anonym'),
f"{raw_points}/100",
str(grade_points),
grade_note,
status_display
])
student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm])
student_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTSIZE', (0, 1), (-1, -1), 9),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (2, 1), (4, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]),
]))
story.append(student_table)
def _add_grade_distribution(story, styles, sorted_students):
"""Add grade distribution table."""
story.append(Spacer(1, 0.5*cm))
story.append(Paragraph("Notenverteilung", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
grade_counts = {}
for student in sorted_students:
gp = student.get('grade_points', 0)
grade_counts[gp] = grade_counts.get(gp, 0) + 1
dist_data = [["Punkte", "Note", "Anzahl"]]
for points in range(15, -1, -1):
if points in grade_counts:
note = GRADE_POINTS_TO_NOTE.get(points, "-")
count = grade_counts[points]
dist_data.append([str(points), note, str(count)])
if len(dist_data) > 1:
dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm])
dist_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')),
]))
story.append(dist_table)
def generate_annotations_pdf(
student_data: Dict[str, Any],
klausur_data: Dict[str, Any],
annotations: List[Dict[str, Any]]
) -> bytes:
"""
Generate a PDF with all annotations for a student work.
Args:
student_data: Student work data
klausur_data: Klausur metadata
annotations: List of all annotations
Returns:
PDF as bytes
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
styles = get_custom_styles()
story = []
# Header
story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle']))
story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle']))
story.append(Spacer(1, 0.5*cm))
if not annotations:
story.append(Paragraph("<i>Keine Anmerkungen vorhanden.</i>", styles['GutachtenBody']))
else:
# Group by type
by_type = {}
for ann in annotations:
ann_type = ann.get('type', 'comment')
if ann_type not in by_type:
by_type[ann_type] = []
by_type[ann_type].append(ann)
for ann_type, anns in by_type.items():
type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title())
story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader']))
story.append(Spacer(1, 0.2*cm))
sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0)))
for idx, ann in enumerate(sorted_anns, 1):
page = ann.get('page', 1)
text = ann.get('text', '')
suggestion = ann.get('suggestion', '')
severity = ann.get('severity', 'minor')
ann_text = f"<b>[S.{page}]</b> {text}"
if suggestion:
ann_text += f" -> <i>{suggestion}</i>"
if severity == 'critical':
ann_text = f"<font color='red'>{ann_text}</font>"
elif severity == 'major':
ann_text = f"<font color='orange'>{ann_text}</font>"
story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem']))
story.append(Spacer(1, 0.3*cm))
# Footer
story.append(Spacer(1, 1*cm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0')))
story.append(Spacer(1, 0.2*cm))
story.append(Paragraph(
f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System",
styles['MetaText']
))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
@@ -0,0 +1,110 @@
"""
PDF Export - Constants and ReportLab styles for Abiturkorrektur PDFs.
"""
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
# =============================================
# CONSTANTS
# =============================================
GRADE_POINTS_TO_NOTE = {
15: "1+", 14: "1", 13: "1-",
12: "2+", 11: "2", 10: "2-",
9: "3+", 8: "3", 7: "3-",
6: "4+", 5: "4", 4: "4-",
3: "5+", 2: "5", 1: "5-",
0: "6"
}
CRITERIA_DISPLAY_NAMES = {
"rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)",
"grammatik": "Sprachliche Richtigkeit (Grammatik)",
"inhalt": "Inhaltliche Leistung",
"struktur": "Aufbau und Struktur",
"stil": "Ausdruck und Stil"
}
CRITERIA_WEIGHTS = {
"rechtschreibung": 15,
"grammatik": 15,
"inhalt": 40,
"struktur": 15,
"stil": 15
}
# =============================================
# STYLES
# =============================================
def get_custom_styles():
"""Create custom paragraph styles for Gutachten."""
styles = getSampleStyleSheet()
# Title style
styles.add(ParagraphStyle(
name='GutachtenTitle',
parent=styles['Heading1'],
fontSize=16,
spaceAfter=12,
alignment=TA_CENTER,
textColor=colors.HexColor('#1e3a5f')
))
# Subtitle style
styles.add(ParagraphStyle(
name='GutachtenSubtitle',
parent=styles['Heading2'],
fontSize=12,
spaceAfter=8,
spaceBefore=16,
textColor=colors.HexColor('#2c5282')
))
# Section header
styles.add(ParagraphStyle(
name='SectionHeader',
parent=styles['Heading3'],
fontSize=11,
spaceAfter=6,
spaceBefore=12,
textColor=colors.HexColor('#2d3748'),
borderColor=colors.HexColor('#e2e8f0'),
borderWidth=0,
borderPadding=0
))
# Body text
styles.add(ParagraphStyle(
name='GutachtenBody',
parent=styles['Normal'],
fontSize=10,
leading=14,
alignment=TA_JUSTIFY,
spaceAfter=6
))
# Small text for footer/meta
styles.add(ParagraphStyle(
name='MetaText',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_LEFT
))
# List item
styles.add(ParagraphStyle(
name='ListItem',
parent=styles['Normal'],
fontSize=10,
leftIndent=20,
bulletIndent=10,
spaceAfter=4
))
return styles
+146
View File
@@ -0,0 +1,146 @@
"""
Qdrant Vector Database Service — QdrantService class for NiBiS Ingestion Pipeline.
"""
from typing import List, Dict, Optional
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct, Filter, FieldCondition, MatchValue
from qdrant_core import QDRANT_URL, VECTOR_SIZE
class QdrantService:
"""
Class-based Qdrant service for flexible collection management.
Used by nibis_ingestion.py for bulk indexing.
"""
def __init__(self, url: str = None):
self.url = url or QDRANT_URL
self._client = None
@property
def client(self) -> QdrantClient:
if self._client is None:
self._client = QdrantClient(url=self.url)
return self._client
async def ensure_collection(self, collection_name: str, vector_size: int = VECTOR_SIZE) -> bool:
"""
Ensure collection exists, create if needed.
Args:
collection_name: Name of the collection
vector_size: Dimension of vectors
Returns:
True if collection exists/created
"""
try:
collections = self.client.get_collections().collections
collection_names = [c.name for c in collections]
if collection_name not in collection_names:
self.client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE
)
)
print(f"Created collection: {collection_name}")
return True
except Exception as e:
print(f"Error ensuring collection: {e}")
return False
async def upsert_points(self, collection_name: str, points: List[Dict]) -> int:
"""
Upsert points into collection.
Args:
collection_name: Target collection
points: List of {id, vector, payload}
Returns:
Number of upserted points
"""
import uuid
if not points:
return 0
qdrant_points = []
for p in points:
# Convert string ID to UUID for Qdrant compatibility
point_id = p["id"]
if isinstance(point_id, str):
# Use uuid5 with DNS namespace for deterministic UUID from string
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id))
qdrant_points.append(
PointStruct(
id=point_id,
vector=p["vector"],
payload={**p.get("payload", {}), "original_id": p["id"]} # Keep original ID in payload
)
)
self.client.upsert(collection_name=collection_name, points=qdrant_points)
return len(qdrant_points)
async def search(
self,
collection_name: str,
query_vector: List[float],
filter_conditions: Optional[Dict] = None,
limit: int = 10
) -> List[Dict]:
"""
Semantic search in collection.
Args:
collection_name: Collection to search
query_vector: Query embedding
filter_conditions: Optional filters (key: value pairs)
limit: Max results
Returns:
List of matching points with scores
"""
query_filter = None
if filter_conditions:
must_conditions = [
FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filter_conditions.items()
]
query_filter = Filter(must=must_conditions)
results = self.client.search(
collection_name=collection_name,
query_vector=query_vector,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"payload": r.payload
}
for r in results
]
async def get_stats(self, collection_name: str) -> Dict:
"""Get collection statistics."""
try:
info = self.client.get_collection(collection_name)
return {
"name": collection_name,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value
}
except Exception as e:
return {"error": str(e), "name": collection_name}
+193
View File
@@ -0,0 +1,193 @@
"""
Qdrant Vector Database Service — core client and BYOEH functions.
"""
import os
from typing import List, Dict, Optional
from qdrant_client import QdrantClient
from qdrant_client.http import models
from qdrant_client.models import VectorParams, Distance, PointStruct, Filter, FieldCondition, MatchValue
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
COLLECTION_NAME = "bp_eh"
VECTOR_SIZE = 1536 # OpenAI text-embedding-3-small
_client: Optional[QdrantClient] = None
def get_qdrant_client() -> QdrantClient:
"""Get or create Qdrant client singleton."""
global _client
if _client is None:
_client = QdrantClient(url=QDRANT_URL)
return _client
async def init_qdrant_collection() -> bool:
"""Initialize Qdrant collection for BYOEH if not exists."""
try:
client = get_qdrant_client()
# Check if collection exists
collections = client.get_collections().collections
collection_names = [c.name for c in collections]
if COLLECTION_NAME not in collection_names:
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=VECTOR_SIZE,
distance=Distance.COSINE
)
)
print(f"Created Qdrant collection: {COLLECTION_NAME}")
else:
print(f"Qdrant collection {COLLECTION_NAME} already exists")
return True
except Exception as e:
print(f"Failed to initialize Qdrant: {e}")
return False
async def index_eh_chunks(
eh_id: str,
tenant_id: str,
subject: str,
chunks: List[Dict]
) -> int:
"""
Index EH chunks in Qdrant.
Args:
eh_id: Erwartungshorizont ID
tenant_id: Tenant/School ID for isolation
subject: Subject (deutsch, englisch, etc.)
chunks: List of {text, embedding, encrypted_content}
Returns:
Number of indexed chunks
"""
client = get_qdrant_client()
points = []
for i, chunk in enumerate(chunks):
point_id = f"{eh_id}_{i}"
points.append(
PointStruct(
id=point_id,
vector=chunk["embedding"],
payload={
"tenant_id": tenant_id,
"eh_id": eh_id,
"chunk_index": i,
"subject": subject,
"encrypted_content": chunk.get("encrypted_content", ""),
"training_allowed": False # ALWAYS FALSE - critical for compliance
}
)
)
if points:
client.upsert(collection_name=COLLECTION_NAME, points=points)
return len(points)
async def search_eh(
query_embedding: List[float],
tenant_id: str,
subject: Optional[str] = None,
limit: int = 5
) -> List[Dict]:
"""
Semantic search in tenant's Erwartungshorizonte.
Args:
query_embedding: Query vector (1536 dimensions)
tenant_id: Tenant ID for isolation
subject: Optional subject filter
limit: Max results
Returns:
List of matching chunks with scores
"""
client = get_qdrant_client()
# Build filter conditions
must_conditions = [
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]
if subject:
must_conditions.append(
FieldCondition(key="subject", match=MatchValue(value=subject))
)
query_filter = Filter(must=must_conditions)
results = client.search(
collection_name=COLLECTION_NAME,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"eh_id": r.payload.get("eh_id"),
"chunk_index": r.payload.get("chunk_index"),
"encrypted_content": r.payload.get("encrypted_content"),
"subject": r.payload.get("subject")
}
for r in results
]
async def delete_eh_vectors(eh_id: str) -> int:
"""
Delete all vectors for a specific Erwartungshorizont.
Args:
eh_id: Erwartungshorizont ID
Returns:
Number of deleted points
"""
client = get_qdrant_client()
# Get all points for this EH first
scroll_result = client.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=Filter(
must=[FieldCondition(key="eh_id", match=MatchValue(value=eh_id))]
),
limit=1000
)
point_ids = [str(p.id) for p in scroll_result[0]]
if point_ids:
client.delete(
collection_name=COLLECTION_NAME,
points_selector=models.PointIdsList(points=point_ids)
)
return len(point_ids)
async def get_collection_info() -> Dict:
"""Get collection statistics."""
try:
client = get_qdrant_client()
info = client.get_collection(COLLECTION_NAME)
return {
"name": COLLECTION_NAME,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value
}
except Exception as e:
return {"error": str(e)}
+231
View File
@@ -0,0 +1,231 @@
"""
Qdrant Vector Database Service — Legal Templates RAG Search.
"""
from typing import List, Dict, Optional
from qdrant_client.models import VectorParams, Distance, Filter, FieldCondition, MatchValue
from qdrant_core import get_qdrant_client
LEGAL_TEMPLATES_COLLECTION = "bp_legal_templates"
LEGAL_TEMPLATES_VECTOR_SIZE = 1024 # BGE-M3
async def init_legal_templates_collection() -> bool:
"""Initialize Qdrant collection for legal templates if not exists."""
try:
client = get_qdrant_client()
collections = client.get_collections().collections
collection_names = [c.name for c in collections]
if LEGAL_TEMPLATES_COLLECTION not in collection_names:
client.create_collection(
collection_name=LEGAL_TEMPLATES_COLLECTION,
vectors_config=VectorParams(
size=LEGAL_TEMPLATES_VECTOR_SIZE,
distance=Distance.COSINE
)
)
print(f"Created Qdrant collection: {LEGAL_TEMPLATES_COLLECTION}")
else:
print(f"Qdrant collection {LEGAL_TEMPLATES_COLLECTION} already exists")
return True
except Exception as e:
print(f"Failed to initialize legal templates collection: {e}")
return False
async def search_legal_templates(
query_embedding: List[float],
template_type: Optional[str] = None,
license_types: Optional[List[str]] = None,
language: Optional[str] = None,
jurisdiction: Optional[str] = None,
attribution_required: Optional[bool] = None,
limit: int = 10
) -> List[Dict]:
"""
Search in legal templates collection for document generation.
Args:
query_embedding: Query vector (1024 dimensions, BGE-M3)
template_type: Filter by template type (privacy_policy, terms_of_service, etc.)
license_types: Filter by license types (cc0, mit, cc_by_4, etc.)
language: Filter by language (de, en)
jurisdiction: Filter by jurisdiction (DE, EU, US, etc.)
attribution_required: Filter by attribution requirement
limit: Max results
Returns:
List of matching template chunks with full metadata
"""
client = get_qdrant_client()
# Build filter conditions
must_conditions = []
if template_type:
must_conditions.append(
FieldCondition(key="template_type", match=MatchValue(value=template_type))
)
if language:
must_conditions.append(
FieldCondition(key="language", match=MatchValue(value=language))
)
if jurisdiction:
must_conditions.append(
FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction))
)
if attribution_required is not None:
must_conditions.append(
FieldCondition(key="attribution_required", match=MatchValue(value=attribution_required))
)
# License type filter (OR condition)
should_conditions = []
if license_types:
for license_type in license_types:
should_conditions.append(
FieldCondition(key="license_id", match=MatchValue(value=license_type))
)
# Construct filter
query_filter = None
if must_conditions or should_conditions:
filter_args = {}
if must_conditions:
filter_args["must"] = must_conditions
if should_conditions:
filter_args["should"] = should_conditions
query_filter = Filter(**filter_args)
try:
results = client.search(
collection_name=LEGAL_TEMPLATES_COLLECTION,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"text": r.payload.get("text", ""),
"document_title": r.payload.get("document_title"),
"template_type": r.payload.get("template_type"),
"clause_category": r.payload.get("clause_category"),
"language": r.payload.get("language"),
"jurisdiction": r.payload.get("jurisdiction"),
"license_id": r.payload.get("license_id"),
"license_name": r.payload.get("license_name"),
"license_url": r.payload.get("license_url"),
"attribution_required": r.payload.get("attribution_required"),
"attribution_text": r.payload.get("attribution_text"),
"source_name": r.payload.get("source_name"),
"source_url": r.payload.get("source_url"),
"source_repo": r.payload.get("source_repo"),
"placeholders": r.payload.get("placeholders", []),
"is_complete_document": r.payload.get("is_complete_document"),
"is_modular": r.payload.get("is_modular"),
"requires_customization": r.payload.get("requires_customization"),
"output_allowed": r.payload.get("output_allowed"),
"modification_allowed": r.payload.get("modification_allowed"),
"distortion_prohibited": r.payload.get("distortion_prohibited"),
}
for r in results
]
except Exception as e:
print(f"Legal templates search error: {e}")
return []
async def get_legal_templates_stats() -> Dict:
"""Get statistics for the legal templates collection."""
try:
client = get_qdrant_client()
info = client.get_collection(LEGAL_TEMPLATES_COLLECTION)
# Count by template type
template_types = ["privacy_policy", "terms_of_service", "cookie_banner",
"impressum", "widerruf", "dpa", "sla", "agb"]
type_counts = {}
for ttype in template_types:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="template_type", match=MatchValue(value=ttype))]
)
)
if result.count > 0:
type_counts[ttype] = result.count
# Count by language
lang_counts = {}
for lang in ["de", "en"]:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="language", match=MatchValue(value=lang))]
)
)
lang_counts[lang] = result.count
# Count by license
license_counts = {}
for license_id in ["cc0", "mit", "cc_by_4", "public_domain", "unlicense"]:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="license_id", match=MatchValue(value=license_id))]
)
)
if result.count > 0:
license_counts[license_id] = result.count
return {
"collection": LEGAL_TEMPLATES_COLLECTION,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value,
"template_types": type_counts,
"languages": lang_counts,
"licenses": license_counts,
}
except Exception as e:
return {"error": str(e), "collection": LEGAL_TEMPLATES_COLLECTION}
async def delete_legal_templates_by_source(source_name: str) -> int:
"""
Delete all legal template chunks from a specific source.
Args:
source_name: Name of the source to delete
Returns:
Number of deleted points
"""
client = get_qdrant_client()
# Count first
count_result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
)
)
# Delete by filter
client.delete(
collection_name=LEGAL_TEMPLATES_COLLECTION,
points_selector=Filter(
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
)
)
return count_result.count
+79
View File
@@ -0,0 +1,79 @@
"""
Qdrant Vector Database Service — NiBiS RAG Search for Klausurkorrektur.
"""
from typing import List, Dict, Optional
from qdrant_client.models import Filter, FieldCondition, MatchValue
from qdrant_core import get_qdrant_client
async def search_nibis_eh(
query_embedding: List[float],
year: Optional[int] = None,
subject: Optional[str] = None,
niveau: Optional[str] = None,
limit: int = 5
) -> List[Dict]:
"""
Search in NiBiS Erwartungshorizonte (public, pre-indexed data).
Unlike search_eh(), this searches in the public NiBiS collection
and returns plaintext (not encrypted).
Args:
query_embedding: Query vector
year: Optional year filter (2016, 2017, 2024, 2025)
subject: Optional subject filter
niveau: Optional niveau filter (eA, gA)
limit: Max results
Returns:
List of matching chunks with metadata
"""
client = get_qdrant_client()
collection = "bp_nibis_eh"
# Build filter
must_conditions = []
if year:
must_conditions.append(
FieldCondition(key="year", match=MatchValue(value=year))
)
if subject:
must_conditions.append(
FieldCondition(key="subject", match=MatchValue(value=subject))
)
if niveau:
must_conditions.append(
FieldCondition(key="niveau", match=MatchValue(value=niveau))
)
query_filter = Filter(must=must_conditions) if must_conditions else None
try:
results = client.search(
collection_name=collection,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"text": r.payload.get("text", ""),
"year": r.payload.get("year"),
"subject": r.payload.get("subject"),
"niveau": r.payload.get("niveau"),
"task_number": r.payload.get("task_number"),
"doc_type": r.payload.get("doc_type"),
"variant": r.payload.get("variant"),
}
for r in results
]
except Exception as e:
print(f"NiBiS search error: {e}")
return []
+30 -630
View File
@@ -1,638 +1,38 @@
"""
Qdrant Vector Database Service for BYOEH
Manages vector storage and semantic search for Erwartungshorizonte.
Qdrant Vector Database Service for BYOEH — barrel re-export.
The actual code lives in:
- qdrant_core.py (client singleton, BYOEH index/search/delete)
- qdrant_class.py (QdrantService class for NiBiS pipeline)
- qdrant_nibis.py (NiBiS RAG search)
- qdrant_legal.py (Legal Templates RAG search)
"""
import os
from typing import List, Dict, Optional
from qdrant_client import QdrantClient
from qdrant_client.http import models
from qdrant_client.models import VectorParams, Distance, PointStruct, Filter, FieldCondition, MatchValue
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
COLLECTION_NAME = "bp_eh"
VECTOR_SIZE = 1536 # OpenAI text-embedding-3-small
_client: Optional[QdrantClient] = None
def get_qdrant_client() -> QdrantClient:
"""Get or create Qdrant client singleton."""
global _client
if _client is None:
_client = QdrantClient(url=QDRANT_URL)
return _client
async def init_qdrant_collection() -> bool:
"""Initialize Qdrant collection for BYOEH if not exists."""
try:
client = get_qdrant_client()
# Check if collection exists
collections = client.get_collections().collections
collection_names = [c.name for c in collections]
if COLLECTION_NAME not in collection_names:
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=VECTOR_SIZE,
distance=Distance.COSINE
)
)
print(f"Created Qdrant collection: {COLLECTION_NAME}")
else:
print(f"Qdrant collection {COLLECTION_NAME} already exists")
return True
except Exception as e:
print(f"Failed to initialize Qdrant: {e}")
return False
async def index_eh_chunks(
eh_id: str,
tenant_id: str,
subject: str,
chunks: List[Dict]
) -> int:
"""
Index EH chunks in Qdrant.
Args:
eh_id: Erwartungshorizont ID
tenant_id: Tenant/School ID for isolation
subject: Subject (deutsch, englisch, etc.)
chunks: List of {text, embedding, encrypted_content}
Returns:
Number of indexed chunks
"""
client = get_qdrant_client()
points = []
for i, chunk in enumerate(chunks):
point_id = f"{eh_id}_{i}"
points.append(
PointStruct(
id=point_id,
vector=chunk["embedding"],
payload={
"tenant_id": tenant_id,
"eh_id": eh_id,
"chunk_index": i,
"subject": subject,
"encrypted_content": chunk.get("encrypted_content", ""),
"training_allowed": False # ALWAYS FALSE - critical for compliance
}
)
# Core client & BYOEH functions
from qdrant_core import ( # noqa: F401
QDRANT_URL,
COLLECTION_NAME,
VECTOR_SIZE,
get_qdrant_client,
init_qdrant_collection,
index_eh_chunks,
search_eh,
delete_eh_vectors,
get_collection_info,
)
if points:
client.upsert(collection_name=COLLECTION_NAME, points=points)
# Class-based service
from qdrant_class import QdrantService # noqa: F401
return len(points)
# NiBiS search
from qdrant_nibis import search_nibis_eh # noqa: F401
async def search_eh(
query_embedding: List[float],
tenant_id: str,
subject: Optional[str] = None,
limit: int = 5
) -> List[Dict]:
"""
Semantic search in tenant's Erwartungshorizonte.
Args:
query_embedding: Query vector (1536 dimensions)
tenant_id: Tenant ID for isolation
subject: Optional subject filter
limit: Max results
Returns:
List of matching chunks with scores
"""
client = get_qdrant_client()
# Build filter conditions
must_conditions = [
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))
]
if subject:
must_conditions.append(
FieldCondition(key="subject", match=MatchValue(value=subject))
# Legal templates
from qdrant_legal import ( # noqa: F401
LEGAL_TEMPLATES_COLLECTION,
LEGAL_TEMPLATES_VECTOR_SIZE,
init_legal_templates_collection,
search_legal_templates,
get_legal_templates_stats,
delete_legal_templates_by_source,
)
query_filter = Filter(must=must_conditions)
results = client.search(
collection_name=COLLECTION_NAME,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"eh_id": r.payload.get("eh_id"),
"chunk_index": r.payload.get("chunk_index"),
"encrypted_content": r.payload.get("encrypted_content"),
"subject": r.payload.get("subject")
}
for r in results
]
async def delete_eh_vectors(eh_id: str) -> int:
"""
Delete all vectors for a specific Erwartungshorizont.
Args:
eh_id: Erwartungshorizont ID
Returns:
Number of deleted points
"""
client = get_qdrant_client()
# Get all points for this EH first
scroll_result = client.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=Filter(
must=[FieldCondition(key="eh_id", match=MatchValue(value=eh_id))]
),
limit=1000
)
point_ids = [str(p.id) for p in scroll_result[0]]
if point_ids:
client.delete(
collection_name=COLLECTION_NAME,
points_selector=models.PointIdsList(points=point_ids)
)
return len(point_ids)
async def get_collection_info() -> Dict:
"""Get collection statistics."""
try:
client = get_qdrant_client()
info = client.get_collection(COLLECTION_NAME)
return {
"name": COLLECTION_NAME,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value
}
except Exception as e:
return {"error": str(e)}
# =============================================================================
# QdrantService Class (for NiBiS Ingestion Pipeline)
# =============================================================================
class QdrantService:
"""
Class-based Qdrant service for flexible collection management.
Used by nibis_ingestion.py for bulk indexing.
"""
def __init__(self, url: str = None):
self.url = url or QDRANT_URL
self._client = None
@property
def client(self) -> QdrantClient:
if self._client is None:
self._client = QdrantClient(url=self.url)
return self._client
async def ensure_collection(self, collection_name: str, vector_size: int = VECTOR_SIZE) -> bool:
"""
Ensure collection exists, create if needed.
Args:
collection_name: Name of the collection
vector_size: Dimension of vectors
Returns:
True if collection exists/created
"""
try:
collections = self.client.get_collections().collections
collection_names = [c.name for c in collections]
if collection_name not in collection_names:
self.client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE
)
)
print(f"Created collection: {collection_name}")
return True
except Exception as e:
print(f"Error ensuring collection: {e}")
return False
async def upsert_points(self, collection_name: str, points: List[Dict]) -> int:
"""
Upsert points into collection.
Args:
collection_name: Target collection
points: List of {id, vector, payload}
Returns:
Number of upserted points
"""
import uuid
if not points:
return 0
qdrant_points = []
for p in points:
# Convert string ID to UUID for Qdrant compatibility
point_id = p["id"]
if isinstance(point_id, str):
# Use uuid5 with DNS namespace for deterministic UUID from string
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id))
qdrant_points.append(
PointStruct(
id=point_id,
vector=p["vector"],
payload={**p.get("payload", {}), "original_id": p["id"]} # Keep original ID in payload
)
)
self.client.upsert(collection_name=collection_name, points=qdrant_points)
return len(qdrant_points)
async def search(
self,
collection_name: str,
query_vector: List[float],
filter_conditions: Optional[Dict] = None,
limit: int = 10
) -> List[Dict]:
"""
Semantic search in collection.
Args:
collection_name: Collection to search
query_vector: Query embedding
filter_conditions: Optional filters (key: value pairs)
limit: Max results
Returns:
List of matching points with scores
"""
query_filter = None
if filter_conditions:
must_conditions = [
FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filter_conditions.items()
]
query_filter = Filter(must=must_conditions)
results = self.client.search(
collection_name=collection_name,
query_vector=query_vector,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"payload": r.payload
}
for r in results
]
async def get_stats(self, collection_name: str) -> Dict:
"""Get collection statistics."""
try:
info = self.client.get_collection(collection_name)
return {
"name": collection_name,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value
}
except Exception as e:
return {"error": str(e), "name": collection_name}
# =============================================================================
# NiBiS RAG Search (for Klausurkorrektur Module)
# =============================================================================
async def search_nibis_eh(
query_embedding: List[float],
year: Optional[int] = None,
subject: Optional[str] = None,
niveau: Optional[str] = None,
limit: int = 5
) -> List[Dict]:
"""
Search in NiBiS Erwartungshorizonte (public, pre-indexed data).
Unlike search_eh(), this searches in the public NiBiS collection
and returns plaintext (not encrypted).
Args:
query_embedding: Query vector
year: Optional year filter (2016, 2017, 2024, 2025)
subject: Optional subject filter
niveau: Optional niveau filter (eA, gA)
limit: Max results
Returns:
List of matching chunks with metadata
"""
client = get_qdrant_client()
collection = "bp_nibis_eh"
# Build filter
must_conditions = []
if year:
must_conditions.append(
FieldCondition(key="year", match=MatchValue(value=year))
)
if subject:
must_conditions.append(
FieldCondition(key="subject", match=MatchValue(value=subject))
)
if niveau:
must_conditions.append(
FieldCondition(key="niveau", match=MatchValue(value=niveau))
)
query_filter = Filter(must=must_conditions) if must_conditions else None
try:
results = client.search(
collection_name=collection,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"text": r.payload.get("text", ""),
"year": r.payload.get("year"),
"subject": r.payload.get("subject"),
"niveau": r.payload.get("niveau"),
"task_number": r.payload.get("task_number"),
"doc_type": r.payload.get("doc_type"),
"variant": r.payload.get("variant"),
}
for r in results
]
except Exception as e:
print(f"NiBiS search error: {e}")
return []
# =============================================================================
# Legal Templates RAG Search (for Document Generator)
# =============================================================================
LEGAL_TEMPLATES_COLLECTION = "bp_legal_templates"
LEGAL_TEMPLATES_VECTOR_SIZE = 1024 # BGE-M3
async def init_legal_templates_collection() -> bool:
"""Initialize Qdrant collection for legal templates if not exists."""
try:
client = get_qdrant_client()
collections = client.get_collections().collections
collection_names = [c.name for c in collections]
if LEGAL_TEMPLATES_COLLECTION not in collection_names:
client.create_collection(
collection_name=LEGAL_TEMPLATES_COLLECTION,
vectors_config=VectorParams(
size=LEGAL_TEMPLATES_VECTOR_SIZE,
distance=Distance.COSINE
)
)
print(f"Created Qdrant collection: {LEGAL_TEMPLATES_COLLECTION}")
else:
print(f"Qdrant collection {LEGAL_TEMPLATES_COLLECTION} already exists")
return True
except Exception as e:
print(f"Failed to initialize legal templates collection: {e}")
return False
async def search_legal_templates(
query_embedding: List[float],
template_type: Optional[str] = None,
license_types: Optional[List[str]] = None,
language: Optional[str] = None,
jurisdiction: Optional[str] = None,
attribution_required: Optional[bool] = None,
limit: int = 10
) -> List[Dict]:
"""
Search in legal templates collection for document generation.
Args:
query_embedding: Query vector (1024 dimensions, BGE-M3)
template_type: Filter by template type (privacy_policy, terms_of_service, etc.)
license_types: Filter by license types (cc0, mit, cc_by_4, etc.)
language: Filter by language (de, en)
jurisdiction: Filter by jurisdiction (DE, EU, US, etc.)
attribution_required: Filter by attribution requirement
limit: Max results
Returns:
List of matching template chunks with full metadata
"""
client = get_qdrant_client()
# Build filter conditions
must_conditions = []
if template_type:
must_conditions.append(
FieldCondition(key="template_type", match=MatchValue(value=template_type))
)
if language:
must_conditions.append(
FieldCondition(key="language", match=MatchValue(value=language))
)
if jurisdiction:
must_conditions.append(
FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction))
)
if attribution_required is not None:
must_conditions.append(
FieldCondition(key="attribution_required", match=MatchValue(value=attribution_required))
)
# License type filter (OR condition)
should_conditions = []
if license_types:
for license_type in license_types:
should_conditions.append(
FieldCondition(key="license_id", match=MatchValue(value=license_type))
)
# Construct filter
query_filter = None
if must_conditions or should_conditions:
filter_args = {}
if must_conditions:
filter_args["must"] = must_conditions
if should_conditions:
filter_args["should"] = should_conditions
query_filter = Filter(**filter_args)
try:
results = client.search(
collection_name=LEGAL_TEMPLATES_COLLECTION,
query_vector=query_embedding,
query_filter=query_filter,
limit=limit
)
return [
{
"id": str(r.id),
"score": r.score,
"text": r.payload.get("text", ""),
"document_title": r.payload.get("document_title"),
"template_type": r.payload.get("template_type"),
"clause_category": r.payload.get("clause_category"),
"language": r.payload.get("language"),
"jurisdiction": r.payload.get("jurisdiction"),
"license_id": r.payload.get("license_id"),
"license_name": r.payload.get("license_name"),
"license_url": r.payload.get("license_url"),
"attribution_required": r.payload.get("attribution_required"),
"attribution_text": r.payload.get("attribution_text"),
"source_name": r.payload.get("source_name"),
"source_url": r.payload.get("source_url"),
"source_repo": r.payload.get("source_repo"),
"placeholders": r.payload.get("placeholders", []),
"is_complete_document": r.payload.get("is_complete_document"),
"is_modular": r.payload.get("is_modular"),
"requires_customization": r.payload.get("requires_customization"),
"output_allowed": r.payload.get("output_allowed"),
"modification_allowed": r.payload.get("modification_allowed"),
"distortion_prohibited": r.payload.get("distortion_prohibited"),
}
for r in results
]
except Exception as e:
print(f"Legal templates search error: {e}")
return []
async def get_legal_templates_stats() -> Dict:
"""Get statistics for the legal templates collection."""
try:
client = get_qdrant_client()
info = client.get_collection(LEGAL_TEMPLATES_COLLECTION)
# Count by template type
template_types = ["privacy_policy", "terms_of_service", "cookie_banner",
"impressum", "widerruf", "dpa", "sla", "agb"]
type_counts = {}
for ttype in template_types:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="template_type", match=MatchValue(value=ttype))]
)
)
if result.count > 0:
type_counts[ttype] = result.count
# Count by language
lang_counts = {}
for lang in ["de", "en"]:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="language", match=MatchValue(value=lang))]
)
)
lang_counts[lang] = result.count
# Count by license
license_counts = {}
for license_id in ["cc0", "mit", "cc_by_4", "public_domain", "unlicense"]:
result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="license_id", match=MatchValue(value=license_id))]
)
)
if result.count > 0:
license_counts[license_id] = result.count
return {
"collection": LEGAL_TEMPLATES_COLLECTION,
"vectors_count": info.vectors_count,
"points_count": info.points_count,
"status": info.status.value,
"template_types": type_counts,
"languages": lang_counts,
"licenses": license_counts,
}
except Exception as e:
return {"error": str(e), "collection": LEGAL_TEMPLATES_COLLECTION}
async def delete_legal_templates_by_source(source_name: str) -> int:
"""
Delete all legal template chunks from a specific source.
Args:
source_name: Name of the source to delete
Returns:
Number of deleted points
"""
client = get_qdrant_client()
# Count first
count_result = client.count(
collection_name=LEGAL_TEMPLATES_COLLECTION,
count_filter=Filter(
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
)
)
# Delete by filter
client.delete(
collection_name=LEGAL_TEMPLATES_COLLECTION,
points_selector=Filter(
must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))]
)
)
return count_result.count
+23 -617
View File
@@ -1,625 +1,31 @@
"""
Training API - Endpoints for managing AI training jobs
Training API — barrel re-export.
Provides endpoints for:
- Starting/stopping training jobs
- Monitoring training progress
- Managing model versions
- Configuring training parameters
- SSE streaming for real-time metrics
Phase 2.2: Server-Sent Events for live progress
The actual code lives in:
- training_models.py (enums, Pydantic models, in-memory state)
- training_simulation.py (simulate_training_progress, SSE generators)
- training_routes.py (FastAPI router + all endpoints)
"""
import os
import json
import uuid
import asyncio
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from enum import Enum
from dataclasses import dataclass, field, asdict
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
# ============================================================================
# ENUMS & MODELS
# ============================================================================
class TrainingStatus(str, Enum):
QUEUED = "queued"
PREPARING = "preparing"
TRAINING = "training"
VALIDATING = "validating"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
CANCELLED = "cancelled"
class ModelType(str, Enum):
ZEUGNIS = "zeugnis"
KLAUSUR = "klausur"
GENERAL = "general"
# Request/Response Models
class TrainingConfig(BaseModel):
"""Configuration for a training job."""
name: str = Field(..., description="Name for the training job")
model_type: ModelType = Field(ModelType.ZEUGNIS, description="Type of model to train")
bundeslaender: List[str] = Field(..., description="List of Bundesland codes to include")
batch_size: int = Field(16, ge=1, le=128)
learning_rate: float = Field(0.00005, ge=0.000001, le=0.1)
epochs: int = Field(10, ge=1, le=100)
warmup_steps: int = Field(500, ge=0, le=10000)
weight_decay: float = Field(0.01, ge=0, le=1)
gradient_accumulation: int = Field(4, ge=1, le=32)
mixed_precision: bool = Field(True, description="Use FP16 mixed precision training")
class TrainingMetrics(BaseModel):
"""Metrics from a training job."""
precision: float = 0.0
recall: float = 0.0
f1_score: float = 0.0
accuracy: float = 0.0
loss_history: List[float] = []
val_loss_history: List[float] = []
class TrainingJob(BaseModel):
"""A training job with full details."""
id: str
name: str
model_type: ModelType
status: TrainingStatus
progress: float
current_epoch: int
total_epochs: int
loss: float
val_loss: float
learning_rate: float
documents_processed: int
total_documents: int
started_at: Optional[datetime]
estimated_completion: Optional[datetime]
completed_at: Optional[datetime]
error_message: Optional[str]
metrics: TrainingMetrics
config: TrainingConfig
class ModelVersion(BaseModel):
"""A trained model version."""
id: str
job_id: str
version: str
model_type: ModelType
created_at: datetime
metrics: TrainingMetrics
is_active: bool
size_mb: float
bundeslaender: List[str]
class DatasetStats(BaseModel):
"""Statistics about the training dataset."""
total_documents: int
total_chunks: int
training_allowed: int
by_bundesland: Dict[str, int]
by_doc_type: Dict[str, int]
# ============================================================================
# IN-MEMORY STATE (Replace with database in production)
# ============================================================================
@dataclass
class TrainingState:
"""Global training state."""
jobs: Dict[str, dict] = field(default_factory=dict)
model_versions: Dict[str, dict] = field(default_factory=dict)
active_job_id: Optional[str] = None
_state = TrainingState()
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
async def simulate_training_progress(job_id: str):
"""Simulate training progress (replace with actual training logic)."""
global _state
if job_id not in _state.jobs:
return
job = _state.jobs[job_id]
job["status"] = TrainingStatus.TRAINING.value
job["started_at"] = datetime.now().isoformat()
total_steps = job["total_epochs"] * 100 # Simulate 100 steps per epoch
current_step = 0
while current_step < total_steps and job["status"] == TrainingStatus.TRAINING.value:
# Update progress
progress = (current_step / total_steps) * 100
current_epoch = current_step // 100 + 1
# Simulate decreasing loss
base_loss = 0.8 * (1 - progress / 100) + 0.1
loss = base_loss + (0.05 * (0.5 - (current_step % 100) / 100))
val_loss = loss * 1.1
# Update job state
job["progress"] = progress
job["current_epoch"] = min(current_epoch, job["total_epochs"])
job["loss"] = round(loss, 4)
job["val_loss"] = round(val_loss, 4)
job["documents_processed"] = int((progress / 100) * job["total_documents"])
# Update metrics
job["metrics"]["loss_history"].append(round(loss, 4))
job["metrics"]["val_loss_history"].append(round(val_loss, 4))
job["metrics"]["precision"] = round(0.5 + (progress / 200), 3)
job["metrics"]["recall"] = round(0.45 + (progress / 200), 3)
job["metrics"]["f1_score"] = round(0.47 + (progress / 200), 3)
job["metrics"]["accuracy"] = round(0.6 + (progress / 250), 3)
# Keep only last 50 history points
if len(job["metrics"]["loss_history"]) > 50:
job["metrics"]["loss_history"] = job["metrics"]["loss_history"][-50:]
job["metrics"]["val_loss_history"] = job["metrics"]["val_loss_history"][-50:]
# Estimate completion
if progress > 0:
elapsed = (datetime.now() - datetime.fromisoformat(job["started_at"])).total_seconds()
remaining = (elapsed / progress) * (100 - progress)
job["estimated_completion"] = (datetime.now() + timedelta(seconds=remaining)).isoformat()
current_step += 1
await asyncio.sleep(0.5) # Simulate work
# Mark as completed
if job["status"] == TrainingStatus.TRAINING.value:
job["status"] = TrainingStatus.COMPLETED.value
job["progress"] = 100
job["completed_at"] = datetime.now().isoformat()
# Create model version
version_id = str(uuid.uuid4())
_state.model_versions[version_id] = {
"id": version_id,
"job_id": job_id,
"version": f"v{len(_state.model_versions) + 1}.0",
"model_type": job["model_type"],
"created_at": datetime.now().isoformat(),
"metrics": job["metrics"],
"is_active": True,
"size_mb": 245.7,
"bundeslaender": job["config"]["bundeslaender"],
}
_state.active_job_id = None
# ============================================================================
# ROUTER
# ============================================================================
router = APIRouter(prefix="/api/v1/admin/training", tags=["Training"])
@router.get("/jobs", response_model=List[dict])
async def list_training_jobs():
"""Get all training jobs."""
return list(_state.jobs.values())
@router.get("/jobs/{job_id}", response_model=dict)
async def get_training_job(job_id: str):
"""Get details for a specific training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
return _state.jobs[job_id]
@router.post("/jobs", response_model=dict)
async def create_training_job(config: TrainingConfig, background_tasks: BackgroundTasks):
"""Create and start a new training job."""
global _state
# Check if there's already an active job
if _state.active_job_id:
active_job = _state.jobs.get(_state.active_job_id)
if active_job and active_job["status"] in [
TrainingStatus.TRAINING.value,
TrainingStatus.PREPARING.value,
]:
raise HTTPException(
status_code=409,
detail="Another training job is already running"
# Models & enums
from training_models import ( # noqa: F401
TrainingStatus,
ModelType,
TrainingConfig,
TrainingMetrics,
TrainingJob,
ModelVersion,
DatasetStats,
TrainingState,
_state,
)
# Create job
job_id = str(uuid.uuid4())
job = {
"id": job_id,
"name": config.name,
"model_type": config.model_type.value,
"status": TrainingStatus.QUEUED.value,
"progress": 0,
"current_epoch": 0,
"total_epochs": config.epochs,
"loss": 1.0,
"val_loss": 1.0,
"learning_rate": config.learning_rate,
"documents_processed": 0,
"total_documents": len(config.bundeslaender) * 50, # Estimate
"started_at": None,
"estimated_completion": None,
"completed_at": None,
"error_message": None,
"metrics": {
"precision": 0.0,
"recall": 0.0,
"f1_score": 0.0,
"accuracy": 0.0,
"loss_history": [],
"val_loss_history": [],
},
"config": config.dict(),
}
_state.jobs[job_id] = job
_state.active_job_id = job_id
# Start training in background
background_tasks.add_task(simulate_training_progress, job_id)
return {"id": job_id, "status": "queued", "message": "Training job created"}
@router.post("/jobs/{job_id}/pause", response_model=dict)
async def pause_training_job(job_id: str):
"""Pause a running training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] != TrainingStatus.TRAINING.value:
raise HTTPException(status_code=400, detail="Job is not running")
job["status"] = TrainingStatus.PAUSED.value
return {"success": True, "message": "Training paused"}
@router.post("/jobs/{job_id}/resume", response_model=dict)
async def resume_training_job(job_id: str, background_tasks: BackgroundTasks):
"""Resume a paused training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] != TrainingStatus.PAUSED.value:
raise HTTPException(status_code=400, detail="Job is not paused")
job["status"] = TrainingStatus.TRAINING.value
_state.active_job_id = job_id
background_tasks.add_task(simulate_training_progress, job_id)
return {"success": True, "message": "Training resumed"}
@router.post("/jobs/{job_id}/cancel", response_model=dict)
async def cancel_training_job(job_id: str):
"""Cancel a training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
job["status"] = TrainingStatus.CANCELLED.value
job["completed_at"] = datetime.now().isoformat()
if _state.active_job_id == job_id:
_state.active_job_id = None
return {"success": True, "message": "Training cancelled"}
@router.delete("/jobs/{job_id}", response_model=dict)
async def delete_training_job(job_id: str):
"""Delete a training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] == TrainingStatus.TRAINING.value:
raise HTTPException(status_code=400, detail="Cannot delete running job")
del _state.jobs[job_id]
return {"success": True, "message": "Job deleted"}
# ============================================================================
# MODEL VERSIONS
# ============================================================================
@router.get("/models", response_model=List[dict])
async def list_model_versions():
"""Get all trained model versions."""
return list(_state.model_versions.values())
@router.get("/models/{version_id}", response_model=dict)
async def get_model_version(version_id: str):
"""Get details for a specific model version."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
return _state.model_versions[version_id]
@router.post("/models/{version_id}/activate", response_model=dict)
async def activate_model_version(version_id: str):
"""Set a model version as active."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
# Deactivate all other versions of same type
model = _state.model_versions[version_id]
for v in _state.model_versions.values():
if v["model_type"] == model["model_type"]:
v["is_active"] = False
model["is_active"] = True
return {"success": True, "message": "Model activated"}
@router.delete("/models/{version_id}", response_model=dict)
async def delete_model_version(version_id: str):
"""Delete a model version."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
model = _state.model_versions[version_id]
if model["is_active"]:
raise HTTPException(status_code=400, detail="Cannot delete active model")
del _state.model_versions[version_id]
return {"success": True, "message": "Model deleted"}
# ============================================================================
# DATASET STATS
# ============================================================================
@router.get("/dataset/stats", response_model=dict)
async def get_dataset_stats():
"""Get statistics about the training dataset."""
# Get stats from zeugnis sources
from metrics_db import get_zeugnis_stats
zeugnis_stats = await get_zeugnis_stats()
return {
"total_documents": zeugnis_stats.get("total_documents", 0),
"total_chunks": zeugnis_stats.get("total_documents", 0) * 12, # Estimate ~12 chunks per doc
"training_allowed": zeugnis_stats.get("training_allowed_documents", 0),
"by_bundesland": {
bl["bundesland"]: bl.get("doc_count", 0)
for bl in zeugnis_stats.get("per_bundesland", [])
},
"by_doc_type": {
"verordnung": 150,
"schulordnung": 80,
"handreichung": 45,
"erlass": 30,
},
}
# ============================================================================
# TRAINING STATUS
# ============================================================================
@router.get("/status", response_model=dict)
async def get_training_status():
"""Get overall training system status."""
active_job = None
if _state.active_job_id and _state.active_job_id in _state.jobs:
active_job = _state.jobs[_state.active_job_id]
return {
"is_training": _state.active_job_id is not None and active_job is not None and
active_job["status"] == TrainingStatus.TRAINING.value,
"active_job_id": _state.active_job_id,
"total_jobs": len(_state.jobs),
"completed_jobs": sum(
1 for j in _state.jobs.values()
if j["status"] == TrainingStatus.COMPLETED.value
),
"failed_jobs": sum(
1 for j in _state.jobs.values()
if j["status"] == TrainingStatus.FAILED.value
),
"model_versions": len(_state.model_versions),
"active_models": sum(1 for m in _state.model_versions.values() if m["is_active"]),
}
# ============================================================================
# SERVER-SENT EVENTS (SSE) ENDPOINTS
# ============================================================================
async def training_metrics_generator(job_id: str, request: Request):
"""
SSE generator for streaming training metrics.
Yields JSON-encoded training status updates every 500ms.
"""
while True:
# Check if client disconnected
if await request.is_disconnected():
break
# Get job status
if job_id not in _state.jobs:
yield f"data: {json.dumps({'error': 'Job not found'})}\n\n"
break
job = _state.jobs[job_id]
# Build metrics response
metrics_data = {
"job_id": job["id"],
"status": job["status"],
"progress": job["progress"],
"current_epoch": job["current_epoch"],
"total_epochs": job["total_epochs"],
"current_step": int(job["progress"] * job["total_epochs"]),
"total_steps": job["total_epochs"] * 100,
"elapsed_time_ms": 0,
"estimated_remaining_ms": 0,
"metrics": {
"loss": job["loss"],
"val_loss": job["val_loss"],
"accuracy": job["metrics"]["accuracy"],
"learning_rate": job["learning_rate"]
},
"history": [
{
"epoch": i + 1,
"step": (i + 1) * 10,
"loss": loss,
"val_loss": job["metrics"]["val_loss_history"][i] if i < len(job["metrics"]["val_loss_history"]) else None,
"learning_rate": job["learning_rate"],
"timestamp": 0
}
for i, loss in enumerate(job["metrics"]["loss_history"][-50:])
]
}
# Calculate elapsed time
if job["started_at"]:
started = datetime.fromisoformat(job["started_at"])
metrics_data["elapsed_time_ms"] = int((datetime.now() - started).total_seconds() * 1000)
# Calculate remaining time
if job["estimated_completion"]:
estimated = datetime.fromisoformat(job["estimated_completion"])
metrics_data["estimated_remaining_ms"] = max(0, int((estimated - datetime.now()).total_seconds() * 1000))
# Send SSE event
yield f"data: {json.dumps(metrics_data)}\n\n"
# Check if job completed
if job["status"] in [TrainingStatus.COMPLETED.value, TrainingStatus.FAILED.value, TrainingStatus.CANCELLED.value]:
break
# Wait before next update
await asyncio.sleep(0.5)
@router.get("/metrics/stream")
async def stream_training_metrics(job_id: str, request: Request):
"""
SSE endpoint for streaming training metrics.
Streams real-time training progress for a specific job.
Usage:
const eventSource = new EventSource('/api/v1/admin/training/metrics/stream?job_id=xxx')
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log(data.progress, data.metrics.loss)
}
"""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
return StreamingResponse(
training_metrics_generator(job_id, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
# Simulation helpers
from training_simulation import ( # noqa: F401
simulate_training_progress,
training_metrics_generator,
batch_ocr_progress_generator,
)
async def batch_ocr_progress_generator(images_count: int, request: Request):
"""
SSE generator for batch OCR progress simulation.
In production, this would integrate with actual OCR processing.
"""
import random
for i in range(images_count):
# Check if client disconnected
if await request.is_disconnected():
break
# Simulate processing time
await asyncio.sleep(random.uniform(0.3, 0.8))
progress_data = {
"type": "progress",
"current": i + 1,
"total": images_count,
"progress_percent": ((i + 1) / images_count) * 100,
"elapsed_ms": (i + 1) * 500,
"estimated_remaining_ms": (images_count - i - 1) * 500,
"result": {
"text": f"Sample recognized text for image {i + 1}",
"confidence": round(random.uniform(0.7, 0.98), 2),
"processing_time_ms": random.randint(200, 600),
"from_cache": random.random() < 0.2
}
}
yield f"data: {json.dumps(progress_data)}\n\n"
# Send completion event
yield f"data: {json.dumps({'type': 'complete', 'total_time_ms': images_count * 500, 'processed_count': images_count})}\n\n"
@router.get("/ocr/stream")
async def stream_batch_ocr(images_count: int, request: Request):
"""
SSE endpoint for streaming batch OCR progress.
Simulates batch OCR processing with progress updates.
In production, integrate with actual TrOCR batch processing.
Args:
images_count: Number of images to process
Usage:
const eventSource = new EventSource('/api/v1/admin/training/ocr/stream?images_count=10')
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'progress') {
console.log(`${data.current}/${data.total}`)
}
}
"""
if images_count < 1 or images_count > 100:
raise HTTPException(status_code=400, detail="images_count must be between 1 and 100")
return StreamingResponse(
batch_ocr_progress_generator(images_count, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
# Router
from training_routes import router # noqa: F401
+118
View File
@@ -0,0 +1,118 @@
"""
Training API — enums, request/response models, and in-memory state.
"""
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
from dataclasses import dataclass, field
from pydantic import BaseModel, Field
# ============================================================================
# ENUMS
# ============================================================================
class TrainingStatus(str, Enum):
QUEUED = "queued"
PREPARING = "preparing"
TRAINING = "training"
VALIDATING = "validating"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
CANCELLED = "cancelled"
class ModelType(str, Enum):
ZEUGNIS = "zeugnis"
KLAUSUR = "klausur"
GENERAL = "general"
# ============================================================================
# REQUEST/RESPONSE MODELS
# ============================================================================
class TrainingConfig(BaseModel):
"""Configuration for a training job."""
name: str = Field(..., description="Name for the training job")
model_type: ModelType = Field(ModelType.ZEUGNIS, description="Type of model to train")
bundeslaender: List[str] = Field(..., description="List of Bundesland codes to include")
batch_size: int = Field(16, ge=1, le=128)
learning_rate: float = Field(0.00005, ge=0.000001, le=0.1)
epochs: int = Field(10, ge=1, le=100)
warmup_steps: int = Field(500, ge=0, le=10000)
weight_decay: float = Field(0.01, ge=0, le=1)
gradient_accumulation: int = Field(4, ge=1, le=32)
mixed_precision: bool = Field(True, description="Use FP16 mixed precision training")
class TrainingMetrics(BaseModel):
"""Metrics from a training job."""
precision: float = 0.0
recall: float = 0.0
f1_score: float = 0.0
accuracy: float = 0.0
loss_history: List[float] = []
val_loss_history: List[float] = []
class TrainingJob(BaseModel):
"""A training job with full details."""
id: str
name: str
model_type: ModelType
status: TrainingStatus
progress: float
current_epoch: int
total_epochs: int
loss: float
val_loss: float
learning_rate: float
documents_processed: int
total_documents: int
started_at: Optional[datetime]
estimated_completion: Optional[datetime]
completed_at: Optional[datetime]
error_message: Optional[str]
metrics: TrainingMetrics
config: TrainingConfig
class ModelVersion(BaseModel):
"""A trained model version."""
id: str
job_id: str
version: str
model_type: ModelType
created_at: datetime
metrics: TrainingMetrics
is_active: bool
size_mb: float
bundeslaender: List[str]
class DatasetStats(BaseModel):
"""Statistics about the training dataset."""
total_documents: int
total_chunks: int
training_allowed: int
by_bundesland: Dict[str, int]
by_doc_type: Dict[str, int]
# ============================================================================
# IN-MEMORY STATE (Replace with database in production)
# ============================================================================
@dataclass
class TrainingState:
"""Global training state."""
jobs: Dict[str, dict] = field(default_factory=dict)
model_versions: Dict[str, dict] = field(default_factory=dict)
active_job_id: Optional[str] = None
_state = TrainingState()
+303
View File
@@ -0,0 +1,303 @@
"""
Training API — FastAPI route handlers.
"""
import uuid
from datetime import datetime
from typing import List
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from training_models import (
TrainingStatus,
TrainingConfig,
_state,
)
from training_simulation import (
simulate_training_progress,
training_metrics_generator,
batch_ocr_progress_generator,
)
router = APIRouter(prefix="/api/v1/admin/training", tags=["Training"])
# ============================================================================
# TRAINING JOBS
# ============================================================================
@router.get("/jobs", response_model=List[dict])
async def list_training_jobs():
"""Get all training jobs."""
return list(_state.jobs.values())
@router.get("/jobs/{job_id}", response_model=dict)
async def get_training_job(job_id: str):
"""Get details for a specific training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
return _state.jobs[job_id]
@router.post("/jobs", response_model=dict)
async def create_training_job(config: TrainingConfig, background_tasks: BackgroundTasks):
"""Create and start a new training job."""
# Check if there's already an active job
if _state.active_job_id:
active_job = _state.jobs.get(_state.active_job_id)
if active_job and active_job["status"] in [
TrainingStatus.TRAINING.value,
TrainingStatus.PREPARING.value,
]:
raise HTTPException(
status_code=409,
detail="Another training job is already running"
)
# Create job
job_id = str(uuid.uuid4())
job = {
"id": job_id,
"name": config.name,
"model_type": config.model_type.value,
"status": TrainingStatus.QUEUED.value,
"progress": 0,
"current_epoch": 0,
"total_epochs": config.epochs,
"loss": 1.0,
"val_loss": 1.0,
"learning_rate": config.learning_rate,
"documents_processed": 0,
"total_documents": len(config.bundeslaender) * 50, # Estimate
"started_at": None,
"estimated_completion": None,
"completed_at": None,
"error_message": None,
"metrics": {
"precision": 0.0,
"recall": 0.0,
"f1_score": 0.0,
"accuracy": 0.0,
"loss_history": [],
"val_loss_history": [],
},
"config": config.dict(),
}
_state.jobs[job_id] = job
_state.active_job_id = job_id
# Start training in background
background_tasks.add_task(simulate_training_progress, job_id)
return {"id": job_id, "status": "queued", "message": "Training job created"}
@router.post("/jobs/{job_id}/pause", response_model=dict)
async def pause_training_job(job_id: str):
"""Pause a running training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] != TrainingStatus.TRAINING.value:
raise HTTPException(status_code=400, detail="Job is not running")
job["status"] = TrainingStatus.PAUSED.value
return {"success": True, "message": "Training paused"}
@router.post("/jobs/{job_id}/resume", response_model=dict)
async def resume_training_job(job_id: str, background_tasks: BackgroundTasks):
"""Resume a paused training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] != TrainingStatus.PAUSED.value:
raise HTTPException(status_code=400, detail="Job is not paused")
job["status"] = TrainingStatus.TRAINING.value
_state.active_job_id = job_id
background_tasks.add_task(simulate_training_progress, job_id)
return {"success": True, "message": "Training resumed"}
@router.post("/jobs/{job_id}/cancel", response_model=dict)
async def cancel_training_job(job_id: str):
"""Cancel a training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
job["status"] = TrainingStatus.CANCELLED.value
job["completed_at"] = datetime.now().isoformat()
if _state.active_job_id == job_id:
_state.active_job_id = None
return {"success": True, "message": "Training cancelled"}
@router.delete("/jobs/{job_id}", response_model=dict)
async def delete_training_job(job_id: str):
"""Delete a training job."""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = _state.jobs[job_id]
if job["status"] == TrainingStatus.TRAINING.value:
raise HTTPException(status_code=400, detail="Cannot delete running job")
del _state.jobs[job_id]
return {"success": True, "message": "Job deleted"}
# ============================================================================
# MODEL VERSIONS
# ============================================================================
@router.get("/models", response_model=List[dict])
async def list_model_versions():
"""Get all trained model versions."""
return list(_state.model_versions.values())
@router.get("/models/{version_id}", response_model=dict)
async def get_model_version(version_id: str):
"""Get details for a specific model version."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
return _state.model_versions[version_id]
@router.post("/models/{version_id}/activate", response_model=dict)
async def activate_model_version(version_id: str):
"""Set a model version as active."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
# Deactivate all other versions of same type
model = _state.model_versions[version_id]
for v in _state.model_versions.values():
if v["model_type"] == model["model_type"]:
v["is_active"] = False
model["is_active"] = True
return {"success": True, "message": "Model activated"}
@router.delete("/models/{version_id}", response_model=dict)
async def delete_model_version(version_id: str):
"""Delete a model version."""
if version_id not in _state.model_versions:
raise HTTPException(status_code=404, detail="Model version not found")
model = _state.model_versions[version_id]
if model["is_active"]:
raise HTTPException(status_code=400, detail="Cannot delete active model")
del _state.model_versions[version_id]
return {"success": True, "message": "Model deleted"}
# ============================================================================
# DATASET STATS & STATUS
# ============================================================================
@router.get("/dataset/stats", response_model=dict)
async def get_dataset_stats():
"""Get statistics about the training dataset."""
from metrics_db import get_zeugnis_stats
zeugnis_stats = await get_zeugnis_stats()
return {
"total_documents": zeugnis_stats.get("total_documents", 0),
"total_chunks": zeugnis_stats.get("total_documents", 0) * 12,
"training_allowed": zeugnis_stats.get("training_allowed_documents", 0),
"by_bundesland": {
bl["bundesland"]: bl.get("doc_count", 0)
for bl in zeugnis_stats.get("per_bundesland", [])
},
"by_doc_type": {
"verordnung": 150,
"schulordnung": 80,
"handreichung": 45,
"erlass": 30,
},
}
@router.get("/status", response_model=dict)
async def get_training_status():
"""Get overall training system status."""
active_job = None
if _state.active_job_id and _state.active_job_id in _state.jobs:
active_job = _state.jobs[_state.active_job_id]
return {
"is_training": _state.active_job_id is not None and active_job is not None and
active_job["status"] == TrainingStatus.TRAINING.value,
"active_job_id": _state.active_job_id,
"total_jobs": len(_state.jobs),
"completed_jobs": sum(
1 for j in _state.jobs.values()
if j["status"] == TrainingStatus.COMPLETED.value
),
"failed_jobs": sum(
1 for j in _state.jobs.values()
if j["status"] == TrainingStatus.FAILED.value
),
"model_versions": len(_state.model_versions),
"active_models": sum(1 for m in _state.model_versions.values() if m["is_active"]),
}
# ============================================================================
# SSE ENDPOINTS
# ============================================================================
@router.get("/metrics/stream")
async def stream_training_metrics(job_id: str, request: Request):
"""
SSE endpoint for streaming training metrics.
Streams real-time training progress for a specific job.
"""
if job_id not in _state.jobs:
raise HTTPException(status_code=404, detail="Job not found")
return StreamingResponse(
training_metrics_generator(job_id, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@router.get("/ocr/stream")
async def stream_batch_ocr(images_count: int, request: Request):
"""
SSE endpoint for streaming batch OCR progress.
Simulates batch OCR processing with progress updates.
"""
if images_count < 1 or images_count > 100:
raise HTTPException(status_code=400, detail="images_count must be between 1 and 100")
return StreamingResponse(
batch_ocr_progress_generator(images_count, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@@ -0,0 +1,190 @@
"""
Training API — simulation helper and SSE generators.
"""
import json
import uuid
import asyncio
from datetime import datetime, timedelta
from training_models import TrainingStatus, _state
async def simulate_training_progress(job_id: str):
"""Simulate training progress (replace with actual training logic)."""
if job_id not in _state.jobs:
return
job = _state.jobs[job_id]
job["status"] = TrainingStatus.TRAINING.value
job["started_at"] = datetime.now().isoformat()
total_steps = job["total_epochs"] * 100 # Simulate 100 steps per epoch
current_step = 0
while current_step < total_steps and job["status"] == TrainingStatus.TRAINING.value:
# Update progress
progress = (current_step / total_steps) * 100
current_epoch = current_step // 100 + 1
# Simulate decreasing loss
base_loss = 0.8 * (1 - progress / 100) + 0.1
loss = base_loss + (0.05 * (0.5 - (current_step % 100) / 100))
val_loss = loss * 1.1
# Update job state
job["progress"] = progress
job["current_epoch"] = min(current_epoch, job["total_epochs"])
job["loss"] = round(loss, 4)
job["val_loss"] = round(val_loss, 4)
job["documents_processed"] = int((progress / 100) * job["total_documents"])
# Update metrics
job["metrics"]["loss_history"].append(round(loss, 4))
job["metrics"]["val_loss_history"].append(round(val_loss, 4))
job["metrics"]["precision"] = round(0.5 + (progress / 200), 3)
job["metrics"]["recall"] = round(0.45 + (progress / 200), 3)
job["metrics"]["f1_score"] = round(0.47 + (progress / 200), 3)
job["metrics"]["accuracy"] = round(0.6 + (progress / 250), 3)
# Keep only last 50 history points
if len(job["metrics"]["loss_history"]) > 50:
job["metrics"]["loss_history"] = job["metrics"]["loss_history"][-50:]
job["metrics"]["val_loss_history"] = job["metrics"]["val_loss_history"][-50:]
# Estimate completion
if progress > 0:
elapsed = (datetime.now() - datetime.fromisoformat(job["started_at"])).total_seconds()
remaining = (elapsed / progress) * (100 - progress)
job["estimated_completion"] = (datetime.now() + timedelta(seconds=remaining)).isoformat()
current_step += 1
await asyncio.sleep(0.5) # Simulate work
# Mark as completed
if job["status"] == TrainingStatus.TRAINING.value:
job["status"] = TrainingStatus.COMPLETED.value
job["progress"] = 100
job["completed_at"] = datetime.now().isoformat()
# Create model version
version_id = str(uuid.uuid4())
_state.model_versions[version_id] = {
"id": version_id,
"job_id": job_id,
"version": f"v{len(_state.model_versions) + 1}.0",
"model_type": job["model_type"],
"created_at": datetime.now().isoformat(),
"metrics": job["metrics"],
"is_active": True,
"size_mb": 245.7,
"bundeslaender": job["config"]["bundeslaender"],
}
_state.active_job_id = None
async def training_metrics_generator(job_id: str, request):
"""
SSE generator for streaming training metrics.
Yields JSON-encoded training status updates every 500ms.
"""
while True:
# Check if client disconnected
if await request.is_disconnected():
break
# Get job status
if job_id not in _state.jobs:
yield f"data: {json.dumps({'error': 'Job not found'})}\n\n"
break
job = _state.jobs[job_id]
# Build metrics response
metrics_data = {
"job_id": job["id"],
"status": job["status"],
"progress": job["progress"],
"current_epoch": job["current_epoch"],
"total_epochs": job["total_epochs"],
"current_step": int(job["progress"] * job["total_epochs"]),
"total_steps": job["total_epochs"] * 100,
"elapsed_time_ms": 0,
"estimated_remaining_ms": 0,
"metrics": {
"loss": job["loss"],
"val_loss": job["val_loss"],
"accuracy": job["metrics"]["accuracy"],
"learning_rate": job["learning_rate"]
},
"history": [
{
"epoch": i + 1,
"step": (i + 1) * 10,
"loss": loss,
"val_loss": job["metrics"]["val_loss_history"][i] if i < len(job["metrics"]["val_loss_history"]) else None,
"learning_rate": job["learning_rate"],
"timestamp": 0
}
for i, loss in enumerate(job["metrics"]["loss_history"][-50:])
]
}
# Calculate elapsed time
if job["started_at"]:
started = datetime.fromisoformat(job["started_at"])
metrics_data["elapsed_time_ms"] = int((datetime.now() - started).total_seconds() * 1000)
# Calculate remaining time
if job["estimated_completion"]:
estimated = datetime.fromisoformat(job["estimated_completion"])
metrics_data["estimated_remaining_ms"] = max(0, int((estimated - datetime.now()).total_seconds() * 1000))
# Send SSE event
yield f"data: {json.dumps(metrics_data)}\n\n"
# Check if job completed
if job["status"] in [TrainingStatus.COMPLETED.value, TrainingStatus.FAILED.value, TrainingStatus.CANCELLED.value]:
break
# Wait before next update
await asyncio.sleep(0.5)
async def batch_ocr_progress_generator(images_count: int, request):
"""
SSE generator for batch OCR progress simulation.
In production, this would integrate with actual OCR processing.
"""
import random
for i in range(images_count):
# Check if client disconnected
if await request.is_disconnected():
break
# Simulate processing time
await asyncio.sleep(random.uniform(0.3, 0.8))
progress_data = {
"type": "progress",
"current": i + 1,
"total": images_count,
"progress_percent": ((i + 1) / images_count) * 100,
"elapsed_ms": (i + 1) * 500,
"estimated_remaining_ms": (images_count - i - 1) * 500,
"result": {
"text": f"Sample recognized text for image {i + 1}",
"confidence": round(random.uniform(0.7, 0.98), 2),
"processing_time_ms": random.randint(200, 600),
"from_cache": random.random() < 0.2
}
}
yield f"data: {json.dumps(progress_data)}\n\n"
# Send completion event
yield f"data: {json.dumps({'type': 'complete', 'total_time_ms': images_count * 500, 'processed_count': images_count})}\n\n"
+105
View File
@@ -0,0 +1,105 @@
"""
Zeugnis Crawler - Start/stop/status control functions.
"""
import asyncio
from typing import Optional, Dict, Any
from zeugnis_worker import ZeugnisCrawler, get_crawler_state
_crawler_instance: Optional[ZeugnisCrawler] = None
_crawler_task: Optional[asyncio.Task] = None
async def start_crawler(bundesland: Optional[str] = None, source_id: Optional[str] = None) -> bool:
"""Start the crawler."""
global _crawler_instance, _crawler_task
state = get_crawler_state()
if state.is_running:
return False
state.is_running = True
state.documents_crawled_today = 0
state.documents_indexed_today = 0
state.errors_today = 0
_crawler_instance = ZeugnisCrawler()
await _crawler_instance.init()
async def run_crawler():
try:
from metrics_db import get_pool
pool = await get_pool()
if pool:
async with pool.acquire() as conn:
# Get sources to crawl
if source_id:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources WHERE id = $1",
source_id
)
elif bundesland:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources WHERE bundesland = $1",
bundesland
)
else:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources ORDER BY bundesland"
)
for source in sources:
if not state.is_running:
break
await _crawler_instance.crawl_source(source["id"])
except Exception as e:
print(f"Crawler error: {e}")
finally:
state.is_running = False
if _crawler_instance:
await _crawler_instance.close()
_crawler_task = asyncio.create_task(run_crawler())
return True
async def stop_crawler() -> bool:
"""Stop the crawler."""
global _crawler_task
state = get_crawler_state()
if not state.is_running:
return False
state.is_running = False
if _crawler_task:
_crawler_task.cancel()
try:
await _crawler_task
except asyncio.CancelledError:
pass
return True
def get_crawler_status() -> Dict[str, Any]:
"""Get current crawler status."""
state = get_crawler_state()
return {
"is_running": state.is_running,
"current_source": state.current_source_id,
"current_bundesland": state.current_bundesland,
"queue_length": len(state.queue),
"documents_crawled_today": state.documents_crawled_today,
"documents_indexed_today": state.documents_indexed_today,
"errors_today": state.errors_today,
"last_activity": state.last_activity.isoformat() if state.last_activity else None,
}
+17 -667
View File
@@ -1,676 +1,26 @@
"""
Zeugnis Rights-Aware Crawler
Crawls official government documents about school certificates (Zeugnisse)
from all 16 German federal states. Only indexes documents where AI training
is legally permitted.
Barrel re-export: all public symbols for backward compatibility.
"""
import asyncio
import hashlib
import os
import re
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass, field
import httpx
# Local imports
from zeugnis_models import (
CrawlStatus, LicenseType, DocType, EventType,
BUNDESLAENDER, TRAINING_PERMISSIONS,
generate_id, get_training_allowed, get_bundesland_name,
from zeugnis_text import ( # noqa: F401
extract_text_from_pdf,
extract_text_from_html,
chunk_text,
compute_hash,
)
# =============================================================================
# Configuration
# =============================================================================
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "test-access-key")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "test-secret-key")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-rag")
EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local")
ZEUGNIS_COLLECTION = "bp_zeugnis"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
MAX_RETRIES = 3
RETRY_DELAY = 5 # seconds
REQUEST_TIMEOUT = 30 # seconds
USER_AGENT = "BreakPilot-Zeugnis-Crawler/1.0 (Educational Research)"
# =============================================================================
# Crawler State
# =============================================================================
@dataclass
class CrawlerState:
"""Global crawler state."""
is_running: bool = False
current_source_id: Optional[str] = None
current_bundesland: Optional[str] = None
queue: List[Dict] = field(default_factory=list)
documents_crawled_today: int = 0
documents_indexed_today: int = 0
errors_today: int = 0
last_activity: Optional[datetime] = None
_crawler_state = CrawlerState()
# =============================================================================
# Text Extraction
# =============================================================================
def extract_text_from_pdf(content: bytes) -> str:
"""Extract text from PDF bytes."""
try:
from PyPDF2 import PdfReader
import io
reader = PdfReader(io.BytesIO(content))
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return "\n\n".join(text_parts)
except Exception as e:
print(f"PDF extraction failed: {e}")
return ""
def extract_text_from_html(content: bytes, encoding: str = "utf-8") -> str:
"""Extract text from HTML bytes."""
try:
from bs4 import BeautifulSoup
html = content.decode(encoding, errors="replace")
soup = BeautifulSoup(html, "html.parser")
# Remove script and style elements
for element in soup(["script", "style", "nav", "header", "footer"]):
element.decompose()
# Get text
text = soup.get_text(separator="\n", strip=True)
# Clean up whitespace
lines = [line.strip() for line in text.splitlines() if line.strip()]
return "\n".join(lines)
except Exception as e:
print(f"HTML extraction failed: {e}")
return ""
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
"""Split text into overlapping chunks."""
if not text:
return []
chunks = []
separators = ["\n\n", "\n", ". ", " "]
def split_recursive(text: str, sep_index: int = 0) -> List[str]:
if len(text) <= chunk_size:
return [text] if text.strip() else []
if sep_index >= len(separators):
# Force split at chunk_size
result = []
for i in range(0, len(text), chunk_size - overlap):
chunk = text[i:i + chunk_size]
if chunk.strip():
result.append(chunk)
return result
sep = separators[sep_index]
parts = text.split(sep)
result = []
current = ""
for part in parts:
if len(current) + len(sep) + len(part) <= chunk_size:
current = current + sep + part if current else part
else:
if current.strip():
result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current])
current = part
if current.strip():
result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current])
return result
chunks = split_recursive(text)
# Add overlap
if overlap > 0 and len(chunks) > 1:
overlapped = []
for i, chunk in enumerate(chunks):
if i > 0:
# Add end of previous chunk
prev_end = chunks[i - 1][-overlap:]
chunk = prev_end + chunk
overlapped.append(chunk)
chunks = overlapped
return chunks
def compute_hash(content: bytes) -> str:
"""Compute SHA-256 hash of content."""
return hashlib.sha256(content).hexdigest()
# =============================================================================
# Embedding Generation
# =============================================================================
_embedding_model = None
def get_embedding_model():
"""Get or initialize embedding model."""
global _embedding_model
if _embedding_model is None and EMBEDDING_BACKEND == "local":
try:
from sentence_transformers import SentenceTransformer
_embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
print("Loaded local embedding model: all-MiniLM-L6-v2")
except ImportError:
print("Warning: sentence-transformers not installed")
return _embedding_model
async def generate_embeddings(texts: List[str]) -> List[List[float]]:
"""Generate embeddings for a list of texts."""
if not texts:
return []
if EMBEDDING_BACKEND == "local":
model = get_embedding_model()
if model:
embeddings = model.encode(texts, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
return []
elif EMBEDDING_BACKEND == "openai":
import openai
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
print("Warning: OPENAI_API_KEY not set")
return []
client = openai.AsyncOpenAI(api_key=api_key)
response = await client.embeddings.create(
input=texts,
model="text-embedding-3-small"
from zeugnis_storage import ( # noqa: F401
generate_embeddings,
upload_to_minio,
index_in_qdrant,
)
return [item.embedding for item in response.data]
return []
# =============================================================================
# MinIO Storage
# =============================================================================
async def upload_to_minio(
content: bytes,
bundesland: str,
filename: str,
content_type: str = "application/pdf",
year: Optional[int] = None,
) -> Optional[str]:
"""Upload document to MinIO."""
try:
from minio import Minio
client = Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
from zeugnis_worker import ( # noqa: F401
CrawlerState,
ZeugnisCrawler,
)
# Ensure bucket exists
if not client.bucket_exists(MINIO_BUCKET):
client.make_bucket(MINIO_BUCKET)
# Build path
year_str = str(year) if year else str(datetime.now().year)
object_name = f"landes-daten/{bundesland}/zeugnis/{year_str}/{filename}"
# Upload
import io
client.put_object(
MINIO_BUCKET,
object_name,
io.BytesIO(content),
len(content),
content_type=content_type,
from zeugnis_control import ( # noqa: F401
start_crawler,
stop_crawler,
get_crawler_status,
)
return object_name
except Exception as e:
print(f"MinIO upload failed: {e}")
return None
# =============================================================================
# Qdrant Indexing
# =============================================================================
async def index_in_qdrant(
doc_id: str,
chunks: List[str],
embeddings: List[List[float]],
metadata: Dict[str, Any],
) -> int:
"""Index document chunks in Qdrant."""
try:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
client = QdrantClient(url=QDRANT_URL)
# Ensure collection exists
collections = client.get_collections().collections
if not any(c.name == ZEUGNIS_COLLECTION for c in collections):
vector_size = len(embeddings[0]) if embeddings else 384
client.create_collection(
collection_name=ZEUGNIS_COLLECTION,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE,
),
)
print(f"Created Qdrant collection: {ZEUGNIS_COLLECTION}")
# Create points
points = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
point_id = str(uuid.uuid4())
points.append(PointStruct(
id=point_id,
vector=embedding,
payload={
"document_id": doc_id,
"chunk_index": i,
"chunk_text": chunk[:500], # Store first 500 chars for preview
"bundesland": metadata.get("bundesland"),
"doc_type": metadata.get("doc_type"),
"title": metadata.get("title"),
"source_url": metadata.get("url"),
"training_allowed": metadata.get("training_allowed", False),
"indexed_at": datetime.now().isoformat(),
}
))
# Upsert
if points:
client.upsert(
collection_name=ZEUGNIS_COLLECTION,
points=points,
)
return len(points)
except Exception as e:
print(f"Qdrant indexing failed: {e}")
return 0
# =============================================================================
# Crawler Worker
# =============================================================================
class ZeugnisCrawler:
"""Rights-aware crawler for zeugnis documents."""
def __init__(self):
self.http_client: Optional[httpx.AsyncClient] = None
self.db_pool = None
async def init(self):
"""Initialize crawler resources."""
self.http_client = httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
# Initialize database connection
try:
from metrics_db import get_pool
self.db_pool = await get_pool()
except Exception as e:
print(f"Failed to get database pool: {e}")
async def close(self):
"""Close crawler resources."""
if self.http_client:
await self.http_client.aclose()
async def fetch_url(self, url: str) -> Tuple[Optional[bytes], Optional[str]]:
"""Fetch URL with retry logic."""
for attempt in range(MAX_RETRIES):
try:
response = await self.http_client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
return response.content, content_type
except httpx.HTTPStatusError as e:
print(f"HTTP error {e.response.status_code} for {url}")
if e.response.status_code == 404:
return None, None
except Exception as e:
print(f"Attempt {attempt + 1}/{MAX_RETRIES} failed for {url}: {e}")
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
return None, None
async def crawl_seed_url(
self,
seed_url_id: str,
url: str,
bundesland: str,
doc_type: str,
training_allowed: bool,
) -> Dict[str, Any]:
"""Crawl a single seed URL."""
global _crawler_state
result = {
"seed_url_id": seed_url_id,
"url": url,
"success": False,
"document_id": None,
"indexed": False,
"error": None,
}
try:
# Fetch content
content, content_type = await self.fetch_url(url)
if not content:
result["error"] = "Failed to fetch URL"
return result
# Determine file type
is_pdf = "pdf" in content_type.lower() or url.lower().endswith(".pdf")
# Extract text
if is_pdf:
text = extract_text_from_pdf(content)
filename = url.split("/")[-1] or f"document_{seed_url_id}.pdf"
else:
text = extract_text_from_html(content)
filename = f"document_{seed_url_id}.html"
if not text:
result["error"] = "No text extracted"
return result
# Compute hash for versioning
content_hash = compute_hash(content)
# Upload to MinIO
minio_path = await upload_to_minio(
content,
bundesland,
filename,
content_type=content_type or "application/octet-stream",
)
# Generate document ID
doc_id = generate_id()
# Store document in database
if self.db_pool:
async with self.db_pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO zeugnis_documents
(id, seed_url_id, title, url, content_hash, minio_path,
training_allowed, file_size, content_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
""",
doc_id, seed_url_id, filename, url, content_hash,
minio_path, training_allowed, len(content), content_type
)
result["document_id"] = doc_id
result["success"] = True
_crawler_state.documents_crawled_today += 1
# Only index if training is allowed
if training_allowed:
chunks = chunk_text(text)
if chunks:
embeddings = await generate_embeddings(chunks)
if embeddings:
indexed_count = await index_in_qdrant(
doc_id,
chunks,
embeddings,
{
"bundesland": bundesland,
"doc_type": doc_type,
"title": filename,
"url": url,
"training_allowed": True,
}
)
if indexed_count > 0:
result["indexed"] = True
_crawler_state.documents_indexed_today += 1
# Update database
if self.db_pool:
async with self.db_pool.acquire() as conn:
await conn.execute(
"UPDATE zeugnis_documents SET indexed_in_qdrant = true WHERE id = $1",
doc_id
)
else:
result["indexed"] = False
result["error"] = "Training not allowed for this source"
_crawler_state.last_activity = datetime.now()
except Exception as e:
result["error"] = str(e)
_crawler_state.errors_today += 1
return result
async def crawl_source(self, source_id: str) -> Dict[str, Any]:
"""Crawl all seed URLs for a source."""
global _crawler_state
result = {
"source_id": source_id,
"documents_found": 0,
"documents_indexed": 0,
"errors": [],
"started_at": datetime.now(),
"completed_at": None,
}
if not self.db_pool:
result["errors"].append("Database not available")
return result
try:
async with self.db_pool.acquire() as conn:
# Get source info
source = await conn.fetchrow(
"SELECT * FROM zeugnis_sources WHERE id = $1",
source_id
)
if not source:
result["errors"].append(f"Source not found: {source_id}")
return result
bundesland = source["bundesland"]
training_allowed = source["training_allowed"]
_crawler_state.current_source_id = source_id
_crawler_state.current_bundesland = bundesland
# Get seed URLs
seed_urls = await conn.fetch(
"SELECT * FROM zeugnis_seed_urls WHERE source_id = $1 AND status != 'completed'",
source_id
)
for seed_url in seed_urls:
# Update status to running
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'running' WHERE id = $1",
seed_url["id"]
)
# Crawl
crawl_result = await self.crawl_seed_url(
seed_url["id"],
seed_url["url"],
bundesland,
seed_url["doc_type"],
training_allowed,
)
# Update status
if crawl_result["success"]:
result["documents_found"] += 1
if crawl_result["indexed"]:
result["documents_indexed"] += 1
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'completed', last_crawled = NOW() WHERE id = $1",
seed_url["id"]
)
else:
result["errors"].append(f"{seed_url['url']}: {crawl_result['error']}")
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'failed', error_message = $2 WHERE id = $1",
seed_url["id"], crawl_result["error"]
)
# Small delay between requests
await asyncio.sleep(1)
except Exception as e:
result["errors"].append(str(e))
finally:
result["completed_at"] = datetime.now()
_crawler_state.current_source_id = None
_crawler_state.current_bundesland = None
return result
# =============================================================================
# Crawler Control Functions
# =============================================================================
_crawler_instance: Optional[ZeugnisCrawler] = None
_crawler_task: Optional[asyncio.Task] = None
async def start_crawler(bundesland: Optional[str] = None, source_id: Optional[str] = None) -> bool:
"""Start the crawler."""
global _crawler_state, _crawler_instance, _crawler_task
if _crawler_state.is_running:
return False
_crawler_state.is_running = True
_crawler_state.documents_crawled_today = 0
_crawler_state.documents_indexed_today = 0
_crawler_state.errors_today = 0
_crawler_instance = ZeugnisCrawler()
await _crawler_instance.init()
async def run_crawler():
try:
from metrics_db import get_pool
pool = await get_pool()
if pool:
async with pool.acquire() as conn:
# Get sources to crawl
if source_id:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources WHERE id = $1",
source_id
)
elif bundesland:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources WHERE bundesland = $1",
bundesland
)
else:
sources = await conn.fetch(
"SELECT id, bundesland FROM zeugnis_sources ORDER BY bundesland"
)
for source in sources:
if not _crawler_state.is_running:
break
await _crawler_instance.crawl_source(source["id"])
except Exception as e:
print(f"Crawler error: {e}")
finally:
_crawler_state.is_running = False
if _crawler_instance:
await _crawler_instance.close()
_crawler_task = asyncio.create_task(run_crawler())
return True
async def stop_crawler() -> bool:
"""Stop the crawler."""
global _crawler_state, _crawler_task
if not _crawler_state.is_running:
return False
_crawler_state.is_running = False
if _crawler_task:
_crawler_task.cancel()
try:
await _crawler_task
except asyncio.CancelledError:
pass
return True
def get_crawler_status() -> Dict[str, Any]:
"""Get current crawler status."""
global _crawler_state
return {
"is_running": _crawler_state.is_running,
"current_source": _crawler_state.current_source_id,
"current_bundesland": _crawler_state.current_bundesland,
"queue_length": len(_crawler_state.queue),
"documents_crawled_today": _crawler_state.documents_crawled_today,
"documents_indexed_today": _crawler_state.documents_indexed_today,
"errors_today": _crawler_state.errors_today,
"last_activity": _crawler_state.last_activity.isoformat() if _crawler_state.last_activity else None,
}
+180
View File
@@ -0,0 +1,180 @@
"""
Zeugnis Crawler - Embedding generation, MinIO upload, and Qdrant indexing.
"""
import io
import os
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
# =============================================================================
# Configuration
# =============================================================================
QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "test-access-key")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "test-secret-key")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-rag")
EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local")
ZEUGNIS_COLLECTION = "bp_zeugnis"
# =============================================================================
# Embedding Generation
# =============================================================================
_embedding_model = None
def get_embedding_model():
"""Get or initialize embedding model."""
global _embedding_model
if _embedding_model is None and EMBEDDING_BACKEND == "local":
try:
from sentence_transformers import SentenceTransformer
_embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
print("Loaded local embedding model: all-MiniLM-L6-v2")
except ImportError:
print("Warning: sentence-transformers not installed")
return _embedding_model
async def generate_embeddings(texts: List[str]) -> List[List[float]]:
"""Generate embeddings for a list of texts."""
if not texts:
return []
if EMBEDDING_BACKEND == "local":
model = get_embedding_model()
if model:
embeddings = model.encode(texts, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
return []
elif EMBEDDING_BACKEND == "openai":
import openai
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
print("Warning: OPENAI_API_KEY not set")
return []
client = openai.AsyncOpenAI(api_key=api_key)
response = await client.embeddings.create(
input=texts,
model="text-embedding-3-small"
)
return [item.embedding for item in response.data]
return []
# =============================================================================
# MinIO Storage
# =============================================================================
async def upload_to_minio(
content: bytes,
bundesland: str,
filename: str,
content_type: str = "application/pdf",
year: Optional[int] = None,
) -> Optional[str]:
"""Upload document to MinIO."""
try:
from minio import Minio
client = Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
)
# Ensure bucket exists
if not client.bucket_exists(MINIO_BUCKET):
client.make_bucket(MINIO_BUCKET)
# Build path
year_str = str(year) if year else str(datetime.now().year)
object_name = f"landes-daten/{bundesland}/zeugnis/{year_str}/{filename}"
# Upload
client.put_object(
MINIO_BUCKET,
object_name,
io.BytesIO(content),
len(content),
content_type=content_type,
)
return object_name
except Exception as e:
print(f"MinIO upload failed: {e}")
return None
# =============================================================================
# Qdrant Indexing
# =============================================================================
async def index_in_qdrant(
doc_id: str,
chunks: List[str],
embeddings: List[List[float]],
metadata: Dict[str, Any],
) -> int:
"""Index document chunks in Qdrant."""
try:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
client = QdrantClient(url=QDRANT_URL)
# Ensure collection exists
collections = client.get_collections().collections
if not any(c.name == ZEUGNIS_COLLECTION for c in collections):
vector_size = len(embeddings[0]) if embeddings else 384
client.create_collection(
collection_name=ZEUGNIS_COLLECTION,
vectors_config=VectorParams(
size=vector_size,
distance=Distance.COSINE,
),
)
print(f"Created Qdrant collection: {ZEUGNIS_COLLECTION}")
# Create points
points = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
point_id = str(uuid.uuid4())
points.append(PointStruct(
id=point_id,
vector=embedding,
payload={
"document_id": doc_id,
"chunk_index": i,
"chunk_text": chunk[:500], # Store first 500 chars for preview
"bundesland": metadata.get("bundesland"),
"doc_type": metadata.get("doc_type"),
"title": metadata.get("title"),
"source_url": metadata.get("url"),
"training_allowed": metadata.get("training_allowed", False),
"indexed_at": datetime.now().isoformat(),
}
))
# Upsert
if points:
client.upsert(
collection_name=ZEUGNIS_COLLECTION,
points=points,
)
return len(points)
except Exception as e:
print(f"Qdrant indexing failed: {e}")
return 0
+110
View File
@@ -0,0 +1,110 @@
"""
Zeugnis Crawler - Text extraction, chunking, and hashing utilities.
"""
import hashlib
from typing import List
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
def extract_text_from_pdf(content: bytes) -> str:
"""Extract text from PDF bytes."""
try:
from PyPDF2 import PdfReader
import io
reader = PdfReader(io.BytesIO(content))
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return "\n\n".join(text_parts)
except Exception as e:
print(f"PDF extraction failed: {e}")
return ""
def extract_text_from_html(content: bytes, encoding: str = "utf-8") -> str:
"""Extract text from HTML bytes."""
try:
from bs4 import BeautifulSoup
html = content.decode(encoding, errors="replace")
soup = BeautifulSoup(html, "html.parser")
# Remove script and style elements
for element in soup(["script", "style", "nav", "header", "footer"]):
element.decompose()
# Get text
text = soup.get_text(separator="\n", strip=True)
# Clean up whitespace
lines = [line.strip() for line in text.splitlines() if line.strip()]
return "\n".join(lines)
except Exception as e:
print(f"HTML extraction failed: {e}")
return ""
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
"""Split text into overlapping chunks."""
if not text:
return []
chunks = []
separators = ["\n\n", "\n", ". ", " "]
def split_recursive(text: str, sep_index: int = 0) -> List[str]:
if len(text) <= chunk_size:
return [text] if text.strip() else []
if sep_index >= len(separators):
# Force split at chunk_size
result = []
for i in range(0, len(text), chunk_size - overlap):
chunk = text[i:i + chunk_size]
if chunk.strip():
result.append(chunk)
return result
sep = separators[sep_index]
parts = text.split(sep)
result = []
current = ""
for part in parts:
if len(current) + len(sep) + len(part) <= chunk_size:
current = current + sep + part if current else part
else:
if current.strip():
result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current])
current = part
if current.strip():
result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current])
return result
chunks = split_recursive(text)
# Add overlap
if overlap > 0 and len(chunks) > 1:
overlapped = []
for i, chunk in enumerate(chunks):
if i > 0:
# Add end of previous chunk
prev_end = chunks[i - 1][-overlap:]
chunk = prev_end + chunk
overlapped.append(chunk)
chunks = overlapped
return chunks
def compute_hash(content: bytes) -> str:
"""Compute SHA-256 hash of content."""
return hashlib.sha256(content).hexdigest()
+313
View File
@@ -0,0 +1,313 @@
"""
Zeugnis Crawler - ZeugnisCrawler worker class and CrawlerState.
Crawls official government documents about school certificates from
all 16 German federal states. Only indexes documents where AI training
is legally permitted.
"""
import asyncio
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass, field
import httpx
from zeugnis_models import generate_id
from zeugnis_text import (
extract_text_from_pdf,
extract_text_from_html,
chunk_text,
compute_hash,
)
from zeugnis_storage import (
upload_to_minio,
generate_embeddings,
index_in_qdrant,
)
# =============================================================================
# Configuration
# =============================================================================
MAX_RETRIES = 3
RETRY_DELAY = 5 # seconds
REQUEST_TIMEOUT = 30 # seconds
USER_AGENT = "BreakPilot-Zeugnis-Crawler/1.0 (Educational Research)"
# =============================================================================
# Crawler State
# =============================================================================
@dataclass
class CrawlerState:
"""Global crawler state."""
is_running: bool = False
current_source_id: Optional[str] = None
current_bundesland: Optional[str] = None
queue: List[Dict] = field(default_factory=list)
documents_crawled_today: int = 0
documents_indexed_today: int = 0
errors_today: int = 0
last_activity: Optional[datetime] = None
_crawler_state = CrawlerState()
def get_crawler_state() -> CrawlerState:
"""Get the global crawler state."""
return _crawler_state
# =============================================================================
# Crawler Worker
# =============================================================================
class ZeugnisCrawler:
"""Rights-aware crawler for zeugnis documents."""
def __init__(self):
self.http_client: Optional[httpx.AsyncClient] = None
self.db_pool = None
async def init(self):
"""Initialize crawler resources."""
self.http_client = httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
)
# Initialize database connection
try:
from metrics_db import get_pool
self.db_pool = await get_pool()
except Exception as e:
print(f"Failed to get database pool: {e}")
async def close(self):
"""Close crawler resources."""
if self.http_client:
await self.http_client.aclose()
async def fetch_url(self, url: str) -> Tuple[Optional[bytes], Optional[str]]:
"""Fetch URL with retry logic."""
for attempt in range(MAX_RETRIES):
try:
response = await self.http_client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
return response.content, content_type
except httpx.HTTPStatusError as e:
print(f"HTTP error {e.response.status_code} for {url}")
if e.response.status_code == 404:
return None, None
except Exception as e:
print(f"Attempt {attempt + 1}/{MAX_RETRIES} failed for {url}: {e}")
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
return None, None
async def crawl_seed_url(
self,
seed_url_id: str,
url: str,
bundesland: str,
doc_type: str,
training_allowed: bool,
) -> Dict[str, Any]:
"""Crawl a single seed URL."""
global _crawler_state
result = {
"seed_url_id": seed_url_id,
"url": url,
"success": False,
"document_id": None,
"indexed": False,
"error": None,
}
try:
# Fetch content
content, content_type = await self.fetch_url(url)
if not content:
result["error"] = "Failed to fetch URL"
return result
# Determine file type
is_pdf = "pdf" in content_type.lower() or url.lower().endswith(".pdf")
# Extract text
if is_pdf:
text = extract_text_from_pdf(content)
filename = url.split("/")[-1] or f"document_{seed_url_id}.pdf"
else:
text = extract_text_from_html(content)
filename = f"document_{seed_url_id}.html"
if not text:
result["error"] = "No text extracted"
return result
# Compute hash for versioning
content_hash = compute_hash(content)
# Upload to MinIO
minio_path = await upload_to_minio(
content,
bundesland,
filename,
content_type=content_type or "application/octet-stream",
)
# Generate document ID
doc_id = generate_id()
# Store document in database
if self.db_pool:
async with self.db_pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO zeugnis_documents
(id, seed_url_id, title, url, content_hash, minio_path,
training_allowed, file_size, content_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
""",
doc_id, seed_url_id, filename, url, content_hash,
minio_path, training_allowed, len(content), content_type
)
result["document_id"] = doc_id
result["success"] = True
_crawler_state.documents_crawled_today += 1
# Only index if training is allowed
if training_allowed:
chunks = chunk_text(text)
if chunks:
embeddings = await generate_embeddings(chunks)
if embeddings:
indexed_count = await index_in_qdrant(
doc_id,
chunks,
embeddings,
{
"bundesland": bundesland,
"doc_type": doc_type,
"title": filename,
"url": url,
"training_allowed": True,
}
)
if indexed_count > 0:
result["indexed"] = True
_crawler_state.documents_indexed_today += 1
# Update database
if self.db_pool:
async with self.db_pool.acquire() as conn:
await conn.execute(
"UPDATE zeugnis_documents SET indexed_in_qdrant = true WHERE id = $1",
doc_id
)
else:
result["indexed"] = False
result["error"] = "Training not allowed for this source"
_crawler_state.last_activity = datetime.now()
except Exception as e:
result["error"] = str(e)
_crawler_state.errors_today += 1
return result
async def crawl_source(self, source_id: str) -> Dict[str, Any]:
"""Crawl all seed URLs for a source."""
global _crawler_state
result = {
"source_id": source_id,
"documents_found": 0,
"documents_indexed": 0,
"errors": [],
"started_at": datetime.now(),
"completed_at": None,
}
if not self.db_pool:
result["errors"].append("Database not available")
return result
try:
async with self.db_pool.acquire() as conn:
# Get source info
source = await conn.fetchrow(
"SELECT * FROM zeugnis_sources WHERE id = $1",
source_id
)
if not source:
result["errors"].append(f"Source not found: {source_id}")
return result
bundesland = source["bundesland"]
training_allowed = source["training_allowed"]
_crawler_state.current_source_id = source_id
_crawler_state.current_bundesland = bundesland
# Get seed URLs
seed_urls = await conn.fetch(
"SELECT * FROM zeugnis_seed_urls WHERE source_id = $1 AND status != 'completed'",
source_id
)
for seed_url in seed_urls:
# Update status to running
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'running' WHERE id = $1",
seed_url["id"]
)
# Crawl
crawl_result = await self.crawl_seed_url(
seed_url["id"],
seed_url["url"],
bundesland,
seed_url["doc_type"],
training_allowed,
)
# Update status
if crawl_result["success"]:
result["documents_found"] += 1
if crawl_result["indexed"]:
result["documents_indexed"] += 1
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'completed', last_crawled = NOW() WHERE id = $1",
seed_url["id"]
)
else:
result["errors"].append(f"{seed_url['url']}: {crawl_result['error']}")
await conn.execute(
"UPDATE zeugnis_seed_urls SET status = 'failed', error_message = $2 WHERE id = $1",
seed_url["id"], crawl_result["error"]
)
# Small delay between requests
await asyncio.sleep(1)
except Exception as e:
result["errors"].append(str(e))
finally:
result["completed_at"] = datetime.now()
_crawler_state.current_source_id = None
_crawler_state.current_bundesland = None
return result
@@ -0,0 +1,300 @@
'use client'
import { useState, useEffect } from 'react'
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
// =============================================================================
// GLASS CARD - Ultra Transparent
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0 }: GlassCardProps) {
const { settings } = usePerformance()
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = { sm: 'p-4', md: 'p-5', lg: 'p-6' }
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
return (
<div
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{
background: isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)',
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
opacity: isVisible ? 1 : 0,
transform: isVisible ? `translateY(0) scale(${isHovered ? 1.01 : 1})` : 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// ANALOG CLOCK
// =============================================================================
export function AnalogClock() {
const [time, setTime] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const hours = time.getHours() % 12
const minutes = time.getMinutes()
const seconds = time.getSeconds()
const hourDeg = (hours * 30) + (minutes * 0.5)
const minuteDeg = minutes * 6
const secondDeg = seconds * 6
return (
<div className="relative w-32 h-32">
<div className="absolute inset-0 rounded-full" style={{ background: 'rgba(255, 255, 255, 0.05)', border: '2px solid rgba(255, 255, 255, 0.15)' }}>
{[...Array(12)].map((_, i) => (
<div key={i} className="absolute w-1 h-3 bg-white/40 rounded-full" style={{ left: '50%', top: '8px', transform: `translateX(-50%) rotate(${i * 30}deg)`, transformOrigin: '50% 56px' }} />
))}
<div className="absolute w-1.5 h-10 bg-white rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${hourDeg}deg)`, transformOrigin: 'bottom center' }} />
<div className="absolute w-1 h-14 bg-white/80 rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${minuteDeg}deg)`, transformOrigin: 'bottom center' }} />
<div className="absolute w-0.5 h-14 bg-orange-400 rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${secondDeg}deg)`, transformOrigin: 'bottom center', transition: 'transform 0.1s ease-out' }} />
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// COMPASS
// =============================================================================
export function Compass({ direction = 225 }: { direction?: number }) {
return (
<div className="relative w-24 h-24">
<div className="absolute inset-0 rounded-full" style={{ background: 'rgba(255, 255, 255, 0.05)', border: '2px solid rgba(255, 255, 255, 0.15)' }}>
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
<div className="absolute inset-4" style={{ transform: `rotate(${direction}deg)`, transition: 'transform 0.5s ease-out' }}>
<div className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full" style={{ left: '50%', bottom: '50%', transform: 'translateX(-50%)', transformOrigin: 'bottom center' }} />
<div className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full" style={{ left: '50%', top: '50%', transform: 'translateX(-50%)', transformOrigin: 'top center' }} />
</div>
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// BAR CHART
// =============================================================================
interface BarChartProps {
data: { label: string; value: number; highlight?: boolean }[]
maxValue?: number
}
export function BarChart({ data, maxValue }: BarChartProps) {
const max = maxValue || Math.max(...data.map((d) => d.value))
return (
<div className="flex items-end justify-between gap-2 h-32">
{data.map((item, index) => {
const height = (item.value / max) * 100
return (
<div key={index} className="flex flex-col items-center gap-2 flex-1">
<span className="text-xs text-white/60 font-medium">{item.value}</span>
<div className="w-full rounded-lg transition-all duration-500" style={{
height: `${height}%`, minHeight: 8,
background: item.highlight ? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))' : 'rgba(255, 255, 255, 0.2)',
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
}} />
<span className="text-xs text-white/40">{item.label}</span>
</div>
)
})}
</div>
)
}
// =============================================================================
// TEMPERATURE DISPLAY
// =============================================================================
export function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
const conditionIcons: Record<string, string> = { sunny: '☀️', cloudy: '☁️', rainy: '🌧️', snowy: '🌨️', partly_cloudy: '⛅' }
return (
<div className="text-center">
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
<div className="flex items-start justify-center">
<span className="text-6xl font-extralight text-white">{temp}</span>
<span className="text-2xl text-white/60 mt-2">°C</span>
</div>
<p className="text-white/50 text-sm mt-1 capitalize">{condition.replace('_', ' ')}</p>
</div>
)
}
// =============================================================================
// PROGRESS RING
// =============================================================================
interface ProgressRingProps {
progress: number; size?: number; strokeWidth?: number; label: string; value: string; color?: string
}
export function ProgressRing({ progress, size = 80, strokeWidth = 6, label, value, color = '#a78bfa' }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="rgba(255, 255, 255, 0.1)" strokeWidth={strokeWidth} />
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset} style={{ transition: 'stroke-dashoffset 1s ease-out' }} />
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-light text-white">{value}</span>
</div>
</div>
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// STAT DISPLAY
// =============================================================================
export function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
return (
<div className="text-center">
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
<div className="flex items-baseline justify-center gap-1">
<span className="text-4xl font-light text-white">{value}</span>
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
</div>
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// LIST ITEM
// =============================================================================
export function ListItem({ icon, title, subtitle, value, delay = 0 }: { icon: string; title: string; subtitle?: string; value?: string; delay?: number }) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<div
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
style={{ background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent', opacity: isVisible ? 1 : 0, transform: isVisible ? 'translateX(0)' : 'translateX(-10px)', transition: 'all 0.3s ease-out' }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'rgba(255,255,255,0.08)' }}>{icon}</div>
<div className="flex-1">
<p className="text-white font-medium">{title}</p>
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
</div>
{value && <span className="text-white/50 font-medium">{value}</span>}
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)
}
// =============================================================================
// ACTION BUTTON
// =============================================================================
export function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: { icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number }) {
const [isVisible, setIsVisible] = useState(false)
const [isPressed, setIsPressed] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<button
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
style={{
background: primary ? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))' : 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.08)', color: 'white',
opacity: isVisible ? 1 : 0, transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseDown={() => setIsPressed(true)} onMouseUp={() => setIsPressed(false)} onMouseLeave={() => setIsPressed(false)}
onClick={onClick}
>
<span className="text-xl">{icon}</span>
<span>{label}</span>
</button>
)
}
// =============================================================================
// QUALITY INDICATOR
// =============================================================================
export function QualityIndicator() {
const { metrics, forceQuality } = usePerformance()
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="fixed bottom-6 left-6 z-50" style={{
background: 'rgba(0, 0, 0, 0.3)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.08)', borderRadius: 16,
padding: isExpanded ? 16 : 12, minWidth: isExpanded ? 200 : 'auto', transition: 'all 0.3s ease-out',
}}>
<button onClick={() => setIsExpanded(!isExpanded)} className="flex items-center gap-3 text-white/70 text-sm">
<span className={`w-2 h-2 rounded-full ${metrics.qualityLevel === 'high' ? 'bg-green-400' : metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'}`} />
<span className="font-mono">{metrics.fps} FPS</span>
<span className="text-white/30">|</span>
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
</button>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
<div className="flex gap-1">
{(['high', 'medium', 'low'] as const).map((level) => (
<button key={level} onClick={() => forceQuality(level)} className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${metrics.qualityLevel === level ? 'bg-white/15 text-white' : 'bg-white/5 text-white/40 hover:bg-white/10'}`}>
{level[0].toUpperCase()}
</button>
))}
</div>
</div>
)}
</div>
)
}
+21 -557
View File
@@ -1,503 +1,15 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
// Spatial UI System
import { PerformanceProvider, usePerformance } from '@/lib/spatial-ui/PerformanceContext'
import { FocusProvider } from '@/lib/spatial-ui/FocusContext'
import { FloatingMessage } from '@/components/spatial-ui/FloatingMessage'
/**
* Apple Weather Style Dashboard - Refined Version
*
* Design principles:
* - Photo/gradient background that sets the mood
* - Ultra-translucent cards (~8% opacity)
* - Cards blend INTO the background
* - White text, monochrome palette
* - Subtle blur, minimal shadows
* - Useful info: time, weather, compass
*/
// =============================================================================
// GLASS CARD - Ultra Transparent
// =============================================================================
interface GlassCardProps {
children: React.ReactNode
className?: string
onClick?: () => void
size?: 'sm' | 'md' | 'lg'
delay?: number
}
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0 }: GlassCardProps) {
const { settings } = usePerformance()
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
const sizeClasses = {
sm: 'p-4',
md: 'p-5',
lg: 'p-6',
}
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
return (
<div
className={`
rounded-3xl
${sizeClasses[size]}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
style={{
background: isHovered
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(255, 255, 255, 0.08)',
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
opacity: isVisible ? 1 : 0,
transform: isVisible
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
: 'translateY(20px)',
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
{children}
</div>
)
}
// =============================================================================
// ANALOG CLOCK - Apple Style
// =============================================================================
function AnalogClock() {
const [time, setTime] = useState(new Date())
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const hours = time.getHours() % 12
const minutes = time.getMinutes()
const seconds = time.getSeconds()
const hourDeg = (hours * 30) + (minutes * 0.5)
const minuteDeg = minutes * 6
const secondDeg = seconds * 6
return (
<div className="relative w-32 h-32">
{/* Clock face */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '2px solid rgba(255, 255, 255, 0.15)',
}}
>
{/* Hour markers */}
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute w-1 h-3 bg-white/40 rounded-full"
style={{
left: '50%',
top: '8px',
transform: `translateX(-50%) rotate(${i * 30}deg)`,
transformOrigin: '50% 56px',
}}
/>
))}
{/* Hour hand */}
<div
className="absolute w-1.5 h-10 bg-white rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${hourDeg}deg)`,
transformOrigin: 'bottom center',
}}
/>
{/* Minute hand */}
<div
className="absolute w-1 h-14 bg-white/80 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${minuteDeg}deg)`,
transformOrigin: 'bottom center',
}}
/>
{/* Second hand */}
<div
className="absolute w-0.5 h-14 bg-orange-400 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: `translateX(-50%) rotate(${secondDeg}deg)`,
transformOrigin: 'bottom center',
transition: 'transform 0.1s ease-out',
}}
/>
{/* Center dot */}
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// COMPASS - Apple Weather Style
// =============================================================================
function Compass({ direction = 225 }: { direction?: number }) {
return (
<div className="relative w-24 h-24">
{/* Compass face */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '2px solid rgba(255, 255, 255, 0.15)',
}}
>
{/* Cardinal directions */}
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
{/* Needle */}
<div
className="absolute inset-4"
style={{
transform: `rotate(${direction}deg)`,
transition: 'transform 0.5s ease-out',
}}
>
{/* North (red) */}
<div
className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full"
style={{
left: '50%',
bottom: '50%',
transform: 'translateX(-50%)',
transformOrigin: 'bottom center',
}}
/>
{/* South (white) */}
<div
className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full"
style={{
left: '50%',
top: '50%',
transform: 'translateX(-50%)',
transformOrigin: 'top center',
}}
/>
</div>
{/* Center */}
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
)
}
// =============================================================================
// BAR CHART - Apple Weather Hourly Style
// =============================================================================
interface BarChartProps {
data: { label: string; value: number; highlight?: boolean }[]
maxValue?: number
}
function BarChart({ data, maxValue }: BarChartProps) {
const max = maxValue || Math.max(...data.map((d) => d.value))
return (
<div className="flex items-end justify-between gap-2 h-32">
{data.map((item, index) => {
const height = (item.value / max) * 100
return (
<div key={index} className="flex flex-col items-center gap-2 flex-1">
<span className="text-xs text-white/60 font-medium">{item.value}</span>
<div
className="w-full rounded-lg transition-all duration-500"
style={{
height: `${height}%`,
minHeight: 8,
background: item.highlight
? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))'
: 'rgba(255, 255, 255, 0.2)',
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
}}
/>
<span className="text-xs text-white/40">{item.label}</span>
</div>
)
})}
</div>
)
}
// =============================================================================
// TEMPERATURE DISPLAY
// =============================================================================
function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
const conditionIcons: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
snowy: '🌨️',
partly_cloudy: '⛅',
}
return (
<div className="text-center">
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
<div className="flex items-start justify-center">
<span className="text-6xl font-extralight text-white">{temp}</span>
<span className="text-2xl text-white/60 mt-2">°C</span>
</div>
<p className="text-white/50 text-sm mt-1 capitalize">
{condition.replace('_', ' ')}
</p>
</div>
)
}
// =============================================================================
// PROGRESS RING
// =============================================================================
interface ProgressRingProps {
progress: number
size?: number
strokeWidth?: number
label: string
value: string
color?: string
}
function ProgressRing({ progress, size = 80, strokeWidth = 6, label, value, color = '#a78bfa' }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-light text-white">{value}</span>
</div>
</div>
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// STAT DISPLAY
// =============================================================================
function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
return (
<div className="text-center">
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
<div className="flex items-baseline justify-center gap-1">
<span className="text-4xl font-light text-white">{value}</span>
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
</div>
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
</div>
)
}
// =============================================================================
// LIST ITEM
// =============================================================================
function ListItem({ icon, title, subtitle, value, delay = 0 }: {
icon: string; title: string; subtitle?: string; value?: string; delay?: number
}) {
const [isVisible, setIsVisible] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<div
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
style={{
background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateX(0)' : 'translateX(-10px)',
transition: 'all 0.3s ease-out',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="w-10 h-10 rounded-xl bg-white/8 flex items-center justify-center text-xl"
style={{ background: 'rgba(255,255,255,0.08)' }}>
{icon}
</div>
<div className="flex-1">
<p className="text-white font-medium">{title}</p>
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
</div>
{value && <span className="text-white/50 font-medium">{value}</span>}
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)
}
// =============================================================================
// ACTION BUTTON
// =============================================================================
function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: {
icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number
}) {
const [isVisible, setIsVisible] = useState(false)
const [isPressed, setIsPressed] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay)
return () => clearTimeout(timer)
}, [delay])
return (
<button
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
style={{
background: primary
? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))'
: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.08)',
color: 'white',
opacity: isVisible ? 1 : 0,
transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
onClick={onClick}
>
<span className="text-xl">{icon}</span>
<span>{label}</span>
</button>
)
}
// =============================================================================
// QUALITY INDICATOR
// =============================================================================
function QualityIndicator() {
const { metrics, settings, forceQuality } = usePerformance()
const [isExpanded, setIsExpanded] = useState(false)
return (
<div
className="fixed bottom-6 left-6 z-50"
style={{
background: 'rgba(0, 0, 0, 0.3)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 16,
padding: isExpanded ? 16 : 12,
minWidth: isExpanded ? 200 : 'auto',
transition: 'all 0.3s ease-out',
}}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-3 text-white/70 text-sm"
>
<span className={`w-2 h-2 rounded-full ${
metrics.qualityLevel === 'high' ? 'bg-green-400' :
metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'
}`} />
<span className="font-mono">{metrics.fps} FPS</span>
<span className="text-white/30">|</span>
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
</button>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
<div className="flex gap-1">
{(['high', 'medium', 'low'] as const).map((level) => (
<button
key={level}
onClick={() => forceQuality(level)}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${
metrics.qualityLevel === level
? 'bg-white/15 text-white'
: 'bg-white/5 text-white/40 hover:bg-white/10'
}`}
>
{level[0].toUpperCase()}
</button>
))}
</div>
</div>
)}
</div>
)
}
import {
GlassCard, AnalogClock, Compass, BarChart, TemperatureDisplay,
ProgressRing, StatDisplay, ListItem, ActionButton, QualityIndicator,
} from './_components/DashboardWidgets'
// =============================================================================
// MAIN DASHBOARD
@@ -529,15 +41,10 @@ function DashboardContent() {
const greeting = time.getHours() < 12 ? 'Guten Morgen' : time.getHours() < 18 ? 'Guten Tag' : 'Guten Abend'
// Weekly correction data
const weeklyData = [
{ label: 'Mo', value: 4, highlight: false },
{ label: 'Di', value: 7, highlight: false },
{ label: 'Mi', value: 3, highlight: false },
{ label: 'Do', value: 8, highlight: false },
{ label: 'Fr', value: 6, highlight: true },
{ label: 'Sa', value: 2, highlight: false },
{ label: 'So', value: 0, highlight: false },
{ label: 'Mo', value: 4 }, { label: 'Di', value: 7 }, { label: 'Mi', value: 3 },
{ label: 'Do', value: 8 }, { label: 'Fr', value: 6, highlight: true },
{ label: 'Sa', value: 2 }, { label: 'So', value: 0 },
]
return (
@@ -545,14 +52,9 @@ function DashboardContent() {
{/* Background */}
<div
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900"
style={{
transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`,
transition: 'transform 0.3s ease-out',
}}
style={{ transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`, transition: 'transform 0.3s ease-out' }}
>
{/* Stars */}
<div className="absolute inset-0 opacity-30"
style={{
<div className="absolute inset-0 opacity-30" style={{
backgroundImage: `radial-gradient(2px 2px at 20px 30px, white, transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 90px 40px, white, transparent),
@@ -560,25 +62,15 @@ function DashboardContent() {
radial-gradient(1px 1px at 230px 80px, white, transparent),
radial-gradient(2px 2px at 300px 150px, rgba(255,255,255,0.7), transparent)`,
backgroundSize: '400px 200px',
}}
/>
{/* Ambient glows */}
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20"
style={{
}} />
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20" style={{
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%)',
left: '10%', top: '20%',
transform: `translate(${parallax.x}px, ${parallax.y}px)`,
transition: 'transform 0.5s ease-out',
}}
/>
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15"
style={{
left: '10%', top: '20%', transform: `translate(${parallax.x}px, ${parallax.y}px)`, transition: 'transform 0.5s ease-out',
}} />
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15" style={{
background: 'radial-gradient(circle, rgba(167, 139, 250, 0.5) 0%, transparent 70%)',
right: '5%', bottom: '10%',
transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`,
transition: 'transform 0.5s ease-out',
}}
/>
right: '5%', bottom: '10%', transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`, transition: 'transform 0.5s ease-out',
}} />
</div>
{/* Content */}
@@ -606,8 +98,6 @@ function DashboardContent() {
{/* Main Grid */}
<div className="grid grid-cols-12 gap-4 max-w-7xl mx-auto">
{/* Clock & Weather Row */}
<div className="col-span-3">
<GlassCard size="lg" delay={50}>
<div className="flex flex-col items-center">
@@ -618,13 +108,9 @@ function DashboardContent() {
</div>
</GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={100}>
<TemperatureDisplay temp={8} condition="partly_cloudy" />
</GlassCard>
<GlassCard size="lg" delay={100}><TemperatureDisplay temp={8} condition="partly_cloudy" /></GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={150}>
<div className="flex flex-col items-center">
@@ -634,24 +120,16 @@ function DashboardContent() {
</div>
</GlassCard>
</div>
<div className="col-span-3">
<GlassCard size="lg" delay={200}>
<StatDisplay icon="📋" value="12" label="Offene Korrekturen" />
<div className="mt-4 pt-4 border-t border-white/10 flex justify-around">
<div className="text-center">
<p className="text-xl font-light text-white">28</p>
<p className="text-white/40 text-xs">Diese Woche</p>
</div>
<div className="text-center">
<p className="text-xl font-light text-white">156</p>
<p className="text-white/40 text-xs">Gesamt</p>
</div>
<div className="text-center"><p className="text-xl font-light text-white">28</p><p className="text-white/40 text-xs">Diese Woche</p></div>
<div className="text-center"><p className="text-xl font-light text-white">156</p><p className="text-white/40 text-xs">Gesamt</p></div>
</div>
</GlassCard>
</div>
{/* Bar Chart */}
<div className="col-span-6">
<GlassCard size="lg" delay={250}>
<div className="flex items-center justify-between mb-4">
@@ -662,7 +140,6 @@ function DashboardContent() {
</GlassCard>
</div>
{/* Progress Rings */}
<div className="col-span-3">
<GlassCard size="lg" delay={300}>
<div className="flex justify-around">
@@ -671,8 +148,6 @@ function DashboardContent() {
</div>
</GlassCard>
</div>
{/* Time Saved */}
<div className="col-span-3">
<GlassCard size="lg" delay={350}>
<StatDisplay icon="⏱" value="4.2" unit="h" label="Zeit gespart" />
@@ -680,7 +155,6 @@ function DashboardContent() {
</GlassCard>
</div>
{/* Klausuren List */}
<div className="col-span-8">
<GlassCard size="lg" delay={400}>
<div className="flex items-center justify-between mb-3">
@@ -695,7 +169,6 @@ function DashboardContent() {
</GlassCard>
</div>
{/* Quick Actions */}
<div className="col-span-4">
<GlassCard size="lg" delay={450}>
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60 mb-4">Schnellaktionen</h2>
@@ -706,19 +179,10 @@ function DashboardContent() {
</div>
</GlassCard>
</div>
</div>
</div>
{/* Floating Messages */}
<FloatingMessage
autoDismissMs={12000}
maxQueue={3}
position="top-right"
offset={{ x: 24, y: 24 }}
/>
{/* Quality Indicator */}
<FloatingMessage autoDismissMs={12000} maxQueue={3} position="top-right" offset={{ x: 24, y: 24 }} />
<QualityIndicator />
</div>
)
+32 -572
View File
@@ -2,8 +2,10 @@
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useAlertsB2B, B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
import { InfoBox, TipBox, StepBox } from './InfoBox'
import { useAlertsB2B, Package } from '@/lib/AlertsB2BContext'
import { WizardStep1, WizardStep2, WizardStep3 } from './B2BWizardSteps'
import { WizardStep4, WizardStep5 } from './B2BWizardDetails'
import type { MigrationMethod } from './B2BWizardSteps'
interface B2BMigrationWizardProps {
onComplete: () => void
@@ -11,8 +13,6 @@ interface B2BMigrationWizardProps {
onCancel?: () => void
}
type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigrationWizardProps) {
const { isDark } = useTheme()
const {
@@ -41,7 +41,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
const handleNext = () => {
if (step < totalSteps) {
// Special handling for step transitions
if (step === 1 && companyName.trim()) {
updateTenant({ companyName: companyName.trim() })
}
@@ -64,7 +63,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
}
const completeWizard = () => {
// Save sources based on migration method
if (migrationMethod === 'email' && inboundEmail) {
addSource({
tenantId: tenant.id,
@@ -85,7 +83,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
})
}
// Update settings
updateSettings({
migrationCompleted: true,
wizardCompleted: true,
@@ -124,7 +121,7 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background Blobs - Dashboard Style */}
{/* Animated Background Blobs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
@@ -157,7 +154,7 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
`}</style>
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
{/* Exit Button - Fixed Top Right */}
{/* Exit Button */}
{onCancel && (
<button
onClick={onCancel}
@@ -227,579 +224,42 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
? 'bg-white/10 border-white/20'
: 'bg-white/80 border-black/10 shadow-xl'
}`}>
{/* Step 1: Firmenname */}
{step === 1 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen im B2B-Bereich
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie heisst Ihr Unternehmen?
</p>
<div className="max-w-md mx-auto space-y-4">
<input
type="text"
placeholder="z.B. Hectronic GmbH"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={`w-full px-4 py-3 rounded-xl border text-lg ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
<p>Ihr Firmenname wird verwendet, um:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
<li>Berichte und Digests zu personalisieren</li>
<li>Ihr Dashboard anzupassen</li>
</ul>
</InfoBox>
</div>
</div>
<WizardStep1 companyName={companyName} setCompanyName={setCompanyName} />
)}
{/* Step 2: Template waehlen */}
{step === 2 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Branchenvorlage waehlen
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
</p>
<div className="space-y-4">
{availableTemplates.map((template) => (
<button
key={template.templateId}
onClick={() => setSelectedTemplateId(template.templateId)}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === template.templateId
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
🏭
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{template.templateName}
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{template.templateDescription}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{template.guidedConfig.packageSelector.options.map(pkg => (
<span
key={pkg}
className={`px-2 py-1 rounded-full text-xs font-medium ${
template.guidedConfig.packageSelector.default.includes(pkg)
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</span>
))}
</div>
</div>
{selectedTemplateId === template.templateId && (
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<WizardStep2
availableTemplates={availableTemplates}
selectedTemplateId={selectedTemplateId}
setSelectedTemplateId={setSelectedTemplateId}
/>
)}
</div>
</button>
))}
{/* Custom option */}
<button
onClick={() => setSelectedTemplateId('custom')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === 'custom'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
isDark ? 'bg-white/20' : 'bg-slate-100'
}`}>
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Eigene Konfiguration
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
</p>
</div>
</div>
</button>
</div>
</div>
)}
{/* Step 3: Migration Method */}
{step === 3 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Nutzen Sie bereits Google Alerts?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
</p>
<div className="space-y-4">
{/* Email Forwarding (Recommended) */}
<button
onClick={() => setMigrationMethod('email')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'email'
? 'border-green-500 bg-green-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
📧
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung
</h3>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
Empfohlen
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
Keine Aenderung an Ihren Alerts noetig.
</p>
</div>
</div>
</button>
{/* RSS Import */}
<button
onClick={() => setMigrationMethod('rss')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'rss'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
📡
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feed Import
</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
Eingeschraenkt
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
Google hat RSS fuer viele Konten deaktiviert
</p>
</div>
</div>
</button>
{/* Reconstruction */}
<button
onClick={() => setMigrationMethod('reconstruct')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'reconstruct'
? 'border-amber-500 bg-amber-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
🔄
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Rekonstruktion
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
optimale Konfiguration fuer Sie.
</p>
</div>
</div>
</button>
</div>
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
<p>
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
</p>
</TipBox>
</div>
<WizardStep3 migrationMethod={migrationMethod} setMigrationMethod={setMigrationMethod} />
)}
{/* Step 4: Migration Details */}
{step === 4 && (
<div>
{/* Email Forwarding */}
{migrationMethod === 'email' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung einrichten
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
</p>
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
<p>
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
wir uebernehmen die Verarbeitung und Auswertung.
</p>
</InfoBox>
<div className="space-y-6">
{/* Inbound Email */}
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Ihre eindeutige Weiterleitungsadresse:
</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={inboundEmail}
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
isDark
? 'bg-white/5 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
<WizardStep4
migrationMethod={migrationMethod}
setMigrationMethod={setMigrationMethod}
inboundEmail={inboundEmail}
testEmailSent={testEmailSent}
setTestEmailSent={setTestEmailSent}
rssUrls={rssUrls}
setRssUrls={setRssUrls}
alertDescription={alertDescription}
setAlertDescription={setAlertDescription}
/>
<button
onClick={() => navigator.clipboard.writeText(inboundEmail)}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
>
Kopieren
</button>
</div>
</div>
{/* Steps */}
<div className="space-y-3">
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail Einstellungen Filter und blockierte Adressen</a>
</StepBox>
<StepBox step={2} title="Neuen Filter erstellen">
Klicken Sie auf &quot;Neuen Filter erstellen&quot; und geben Sie bei &quot;Von&quot; ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
</StepBox>
<StepBox step={3} title="Weiterleitung aktivieren">
Waehlen Sie &quot;Weiterleiten an&quot; und fuegen Sie die obige Adresse ein. Aktivieren Sie auch &quot;Filter auf passende Konversationen anwenden&quot;.
</StepBox>
</div>
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
<p>
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
auch weiterhin in Ihrem Posteingang sehen.
</p>
</TipBox>
{/* Test Button */}
<div className={`p-4 rounded-xl border ${
testEmailSent
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
}`}>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
</p>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{testEmailSent
? 'Die Weiterleitung funktioniert!'
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
</p>
</div>
{!testEmailSent && (
<button
onClick={() => setTestEmailSent(true)}
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
>
Test senden
</button>
)}
</div>
</div>
</div>
</>
)}
{/* RSS Import */}
{migrationMethod === 'rss' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feeds importieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
</p>
{/* Warning Box */}
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
<p>
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
kein RSS-Symbol sehen oder die Option &quot;RSS-Feed&quot; nicht verfuegbar ist,
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
</p>
<button
onClick={() => setMigrationMethod('email')}
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
>
Zur E-Mail-Weiterleitung wechseln
</button>
</InfoBox>
<div className="space-y-4">
{rssUrls.map((url, idx) => (
<div key={idx} className="flex gap-2">
<input
type="url"
placeholder="https://www.google.de/alerts/feeds/..."
value={url}
onChange={(e) => {
const newUrls = [...rssUrls]
newUrls[idx] = e.target.value
setRssUrls(newUrls)
}}
className={`flex-1 px-4 py-3 rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
{rssUrls.length > 1 && (
<button
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
</button>
)}
</div>
))}
<button
onClick={() => setRssUrls([...rssUrls, ''])}
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
isDark
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
+ Weiteren Feed hinzufuegen
</button>
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Falls RSS verfuegbar ist:
</p>
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li>1. Oeffnen Sie google.de/alerts</li>
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
</ol>
</div>
</div>
</>
)}
{/* Reconstruction */}
{migrationMethod === 'reconstruct' && (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Was moechten Sie beobachten?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
</p>
<div className="space-y-4">
<textarea
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
value={alertDescription}
onChange={(e) => setAlertDescription(e.target.value)}
rows={6}
className={`w-full px-4 py-3 rounded-xl border resize-none ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
<p>Beschreiben Sie:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Welche Produkte/Services Sie anbieten</li>
<li>Welche Kaeufer/Maerkte relevant sind</li>
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
</ul>
</InfoBox>
{alertDescription.length > 50 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">🤖</span>
<div>
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
KI-Analyse bereit
</p>
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
)}
{/* Step 5: Notification Settings */}
{step === 5 && (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Benachrichtigungen konfigurieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
</p>
<div className="space-y-6">
{/* Regions */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Regionen
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
<button
key={region}
onClick={() => {
if (selectedRegions.includes(region)) {
setSelectedRegions(selectedRegions.filter(r => r !== region))
} else {
setSelectedRegions([...selectedRegions, region])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedRegions.includes(region)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{region}
</button>
))}
</div>
</div>
)}
{/* Packages */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Produktbereiche
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
<button
key={pkg}
onClick={() => {
if (selectedPackages.includes(pkg)) {
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
} else {
setSelectedPackages([...selectedPackages, pkg])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedPackages.includes(pkg)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</button>
))}
</div>
</div>
)}
{/* Summary */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Ihre Konfiguration
</h4>
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li> Firma: <strong>{companyName}</strong></li>
<li> Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
<li> Migration: <strong>{
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
}</strong></li>
<li> Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
<li> Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
<li> Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
</ul>
</div>
<TipBox title="Bereit fuer den Start" icon="🚀">
<p>
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
irrelevante Treffer.
</p>
</TipBox>
</div>
</div>
<WizardStep5
selectedTemplate={selectedTemplate}
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
selectedPackages={selectedPackages}
setSelectedPackages={setSelectedPackages}
companyName={companyName}
migrationMethod={migrationMethod}
/>
)}
</div>
+460
View File
@@ -0,0 +1,460 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
import { InfoBox, TipBox, StepBox } from './InfoBox'
import type { MigrationMethod } from './B2BWizardSteps'
// =============================================================================
// Step 4: Migration Details (Email / RSS / Reconstruct)
// =============================================================================
interface Step4Props {
migrationMethod: MigrationMethod
setMigrationMethod: (v: MigrationMethod) => void
inboundEmail: string
testEmailSent: boolean
setTestEmailSent: (v: boolean) => void
rssUrls: string[]
setRssUrls: (v: string[]) => void
alertDescription: string
setAlertDescription: (v: string) => void
}
export function WizardStep4({
migrationMethod,
setMigrationMethod,
inboundEmail,
testEmailSent,
setTestEmailSent,
rssUrls,
setRssUrls,
alertDescription,
setAlertDescription,
}: Step4Props) {
const { isDark } = useTheme()
return (
<div>
{/* Email Forwarding */}
{migrationMethod === 'email' && (
<EmailForwardingDetails
inboundEmail={inboundEmail}
testEmailSent={testEmailSent}
setTestEmailSent={setTestEmailSent}
/>
)}
{/* RSS Import */}
{migrationMethod === 'rss' && (
<RSSImportDetails
rssUrls={rssUrls}
setRssUrls={setRssUrls}
setMigrationMethod={setMigrationMethod}
/>
)}
{/* Reconstruction */}
{migrationMethod === 'reconstruct' && (
<ReconstructionDetails
alertDescription={alertDescription}
setAlertDescription={setAlertDescription}
/>
)}
</div>
)
}
// =============================================================================
// Email Forwarding Details
// =============================================================================
function EmailForwardingDetails({
inboundEmail,
testEmailSent,
setTestEmailSent,
}: {
inboundEmail: string
testEmailSent: boolean
setTestEmailSent: (v: boolean) => void
}) {
const { isDark } = useTheme()
return (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung einrichten
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
</p>
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
<p>
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
wir uebernehmen die Verarbeitung und Auswertung.
</p>
</InfoBox>
<div className="space-y-6">
{/* Inbound Email */}
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Ihre eindeutige Weiterleitungsadresse:
</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={inboundEmail}
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
isDark
? 'bg-white/5 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
/>
<button
onClick={() => navigator.clipboard.writeText(inboundEmail)}
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
>
Kopieren
</button>
</div>
</div>
{/* Steps */}
<div className="space-y-3">
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail Einstellungen Filter und blockierte Adressen</a>
</StepBox>
<StepBox step={2} title="Neuen Filter erstellen">
Klicken Sie auf &quot;Neuen Filter erstellen&quot; und geben Sie bei &quot;Von&quot; ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
</StepBox>
<StepBox step={3} title="Weiterleitung aktivieren">
Waehlen Sie &quot;Weiterleiten an&quot; und fuegen Sie die obige Adresse ein. Aktivieren Sie auch &quot;Filter auf passende Konversationen anwenden&quot;.
</StepBox>
</div>
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
<p>
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
auch weiterhin in Ihrem Posteingang sehen.
</p>
</TipBox>
{/* Test Button */}
<div className={`p-4 rounded-xl border ${
testEmailSent
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
}`}>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
</p>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{testEmailSent
? 'Die Weiterleitung funktioniert!'
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
</p>
</div>
{!testEmailSent && (
<button
onClick={() => setTestEmailSent(true)}
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
>
Test senden
</button>
)}
</div>
</div>
</div>
</>
)
}
// =============================================================================
// RSS Import Details
// =============================================================================
function RSSImportDetails({
rssUrls,
setRssUrls,
setMigrationMethod,
}: {
rssUrls: string[]
setRssUrls: (v: string[]) => void
setMigrationMethod: (v: MigrationMethod) => void
}) {
const { isDark } = useTheme()
return (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feeds importieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
</p>
{/* Warning Box */}
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
<p>
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
kein RSS-Symbol sehen oder die Option &quot;RSS-Feed&quot; nicht verfuegbar ist,
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
</p>
<button
onClick={() => setMigrationMethod('email')}
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
>
Zur E-Mail-Weiterleitung wechseln
</button>
</InfoBox>
<div className="space-y-4">
{rssUrls.map((url, idx) => (
<div key={idx} className="flex gap-2">
<input
type="url"
placeholder="https://www.google.de/alerts/feeds/..."
value={url}
onChange={(e) => {
const newUrls = [...rssUrls]
newUrls[idx] = e.target.value
setRssUrls(newUrls)
}}
className={`flex-1 px-4 py-3 rounded-lg border ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
{rssUrls.length > 1 && (
<button
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
>
</button>
)}
</div>
))}
<button
onClick={() => setRssUrls([...rssUrls, ''])}
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
isDark
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
+ Weiteren Feed hinzufuegen
</button>
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Falls RSS verfuegbar ist:
</p>
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li>1. Oeffnen Sie google.de/alerts</li>
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
</ol>
</div>
</div>
</>
)
}
// =============================================================================
// Reconstruction Details
// =============================================================================
function ReconstructionDetails({
alertDescription,
setAlertDescription,
}: {
alertDescription: string
setAlertDescription: (v: string) => void
}) {
const { isDark } = useTheme()
return (
<>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Was moechten Sie beobachten?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
</p>
<div className="space-y-4">
<textarea
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
value={alertDescription}
onChange={(e) => setAlertDescription(e.target.value)}
rows={6}
className={`w-full px-4 py-3 rounded-xl border resize-none ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
<p>Beschreiben Sie:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Welche Produkte/Services Sie anbieten</li>
<li>Welche Kaeufer/Maerkte relevant sind</li>
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
</ul>
</InfoBox>
{alertDescription.length > 50 && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
<div className="flex items-center gap-3">
<span className="text-2xl">🤖</span>
<div>
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
KI-Analyse bereit
</p>
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
</p>
</div>
</div>
</div>
)}
</div>
</>
)
}
// =============================================================================
// Step 5: Notification Settings & Summary
// =============================================================================
interface Step5Props {
selectedTemplate: B2BTemplate | undefined
selectedRegions: string[]
setSelectedRegions: (v: string[]) => void
selectedPackages: string[]
setSelectedPackages: (v: string[]) => void
companyName: string
migrationMethod: MigrationMethod
}
export function WizardStep5({
selectedTemplate,
selectedRegions,
setSelectedRegions,
selectedPackages,
setSelectedPackages,
companyName,
migrationMethod,
}: Step5Props) {
const { isDark } = useTheme()
return (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Benachrichtigungen konfigurieren
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
</p>
<div className="space-y-6">
{/* Regions */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Regionen
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
<button
key={region}
onClick={() => {
if (selectedRegions.includes(region)) {
setSelectedRegions(selectedRegions.filter(r => r !== region))
} else {
setSelectedRegions([...selectedRegions, region])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedRegions.includes(region)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{region}
</button>
))}
</div>
</div>
)}
{/* Packages */}
{selectedTemplate && (
<div>
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
Produktbereiche
</label>
<div className="flex flex-wrap gap-2">
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
<button
key={pkg}
onClick={() => {
if (selectedPackages.includes(pkg)) {
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
} else {
setSelectedPackages([...selectedPackages, pkg])
}
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedPackages.includes(pkg)
? 'bg-blue-500 text-white'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</button>
))}
</div>
</div>
)}
{/* Summary */}
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Ihre Konfiguration
</h4>
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<li> Firma: <strong>{companyName}</strong></li>
<li> Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
<li> Migration: <strong>{
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
}</strong></li>
<li> Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
<li> Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
<li> Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
</ul>
</div>
<TipBox title="Bereit fuer den Start" icon="🚀">
<p>
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
irrelevante Treffer.
</p>
</TipBox>
</div>
</div>
)
}
+281
View File
@@ -0,0 +1,281 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
import { InfoBox, TipBox, StepBox } from './InfoBox'
// =============================================================================
// Step 1: Company Name
// =============================================================================
interface Step1Props {
companyName: string
setCompanyName: (v: string) => void
}
export function WizardStep1({ companyName, setCompanyName }: Step1Props) {
const { isDark } = useTheme()
return (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Willkommen im B2B-Bereich
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Wie heisst Ihr Unternehmen?
</p>
<div className="max-w-md mx-auto space-y-4">
<input
type="text"
placeholder="z.B. Hectronic GmbH"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={`w-full px-4 py-3 rounded-xl border text-lg ${
isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
}`}
/>
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
<p>Ihr Firmenname wird verwendet, um:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
<li>Berichte und Digests zu personalisieren</li>
<li>Ihr Dashboard anzupassen</li>
</ul>
</InfoBox>
</div>
</div>
)
}
// =============================================================================
// Step 2: Template Selection
// =============================================================================
interface Step2Props {
availableTemplates: B2BTemplate[]
selectedTemplateId: string | null
setSelectedTemplateId: (v: string | null) => void
}
export function WizardStep2({ availableTemplates, selectedTemplateId, setSelectedTemplateId }: Step2Props) {
const { isDark } = useTheme()
return (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Branchenvorlage waehlen
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
</p>
<div className="space-y-4">
{availableTemplates.map((template) => (
<button
key={template.templateId}
onClick={() => setSelectedTemplateId(template.templateId)}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === template.templateId
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
🏭
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{template.templateName}
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{template.templateDescription}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{template.guidedConfig.packageSelector.options.map(pkg => (
<span
key={pkg}
className={`px-2 py-1 rounded-full text-xs font-medium ${
template.guidedConfig.packageSelector.default.includes(pkg)
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
}`}
>
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
</span>
))}
</div>
</div>
{selectedTemplateId === template.templateId && (
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
</button>
))}
{/* Custom option */}
<button
onClick={() => setSelectedTemplateId('custom')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedTemplateId === 'custom'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
isDark ? 'bg-white/20' : 'bg-slate-100'
}`}>
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Eigene Konfiguration
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
</p>
</div>
</div>
</button>
</div>
</div>
)
}
// =============================================================================
// Step 3: Migration Method
// =============================================================================
export type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
interface Step3Props {
migrationMethod: MigrationMethod
setMigrationMethod: (v: MigrationMethod) => void
}
export function WizardStep3({ migrationMethod, setMigrationMethod }: Step3Props) {
const { isDark } = useTheme()
return (
<div>
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
Nutzen Sie bereits Google Alerts?
</h2>
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
</p>
<div className="space-y-4">
{/* Email Forwarding (Recommended) */}
<button
onClick={() => setMigrationMethod('email')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'email'
? 'border-green-500 bg-green-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
📧
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
E-Mail Weiterleitung
</h3>
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
Empfohlen
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
Keine Aenderung an Ihren Alerts noetig.
</p>
</div>
</div>
</button>
{/* RSS Import */}
<button
onClick={() => setMigrationMethod('rss')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'rss'
? 'border-blue-500 bg-blue-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
📡
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
RSS-Feed Import
</h3>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
Eingeschraenkt
</span>
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
</p>
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
Google hat RSS fuer viele Konten deaktiviert
</p>
</div>
</div>
</button>
{/* Reconstruction */}
<button
onClick={() => setMigrationMethod('reconstruct')}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
migrationMethod === 'reconstruct'
? 'border-amber-500 bg-amber-500/20 shadow-lg'
: isDark
? 'border-white/10 bg-white/5 hover:bg-white/10'
: 'border-slate-200 bg-white hover:bg-slate-50'
}`}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
🔄
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Rekonstruktion
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
optimale Konfiguration fuer Sie.
</p>
</div>
</div>
</button>
</div>
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
<p>
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
</p>
</TipBox>
</div>
)
}
@@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
import { UploadStep, PreviewStep, ResultStep } from './CleanupSteps'
interface CleanupPanelProps {
isOpen: boolean
@@ -15,7 +16,7 @@ interface CleanupCapabilities {
paddleocr_available: boolean
}
interface PreviewResult {
export interface PreviewResult {
has_handwriting: boolean
confidence: number
handwriting_ratio: number
@@ -32,7 +33,7 @@ interface PreviewResult {
}
}
interface PipelineResult {
export interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
@@ -51,7 +52,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
const [maskUrl, setMaskUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
@@ -74,7 +74,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
}, [])
// Load capabilities on mount
const loadCapabilities = useCallback(async () => {
try {
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
@@ -87,7 +86,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
}
}, [getApiUrl])
// Handle file selection
const handleFileSelect = useCallback((selectedFile: File) => {
setFile(selectedFile)
setError(null)
@@ -95,14 +93,11 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
setPipelineResult(null)
setCleanedUrl(null)
setMaskUrl(null)
// Create preview URL
const url = URL.createObjectURL(selectedFile)
setPreviewUrl(url)
setCurrentStep('upload')
}, [])
// Handle drop
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
const droppedFile = e.dataTransfer.files[0]
@@ -111,31 +106,18 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
}
}, [handleFileSelect])
// Preview cleanup
const handlePreview = useCallback(async () => {
if (!file) return
setIsPreviewing(true)
setError(null)
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, { method: 'POST', body: formData })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
setPreviewResult(result)
setCurrentStep('preview')
// Also load capabilities
await loadCapabilities()
} catch (err) {
console.error('Preview failed:', err)
@@ -145,39 +127,27 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
}
}, [file, getApiUrl, loadCapabilities])
// Run full cleanup pipeline
const handleCleanup = useCallback(async () => {
if (!file) return
setIsProcessing(true)
setError(null)
try {
const formData = new FormData()
formData.append('image', file)
formData.append('remove_handwriting', String(removeHandwriting))
formData.append('reconstruct', String(reconstructLayout))
formData.append('inpainting_method', inpaintingMethod)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
method: 'POST',
body: formData
})
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, { method: 'POST', body: formData })
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
const result: PipelineResult = await response.json()
setPipelineResult(result)
// Create cleaned image URL
if (result.cleaned_image_base64) {
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
setCleanedUrl(URL.createObjectURL(cleanedBlob))
}
setCurrentStep('result')
} catch (err) {
console.error('Cleanup failed:', err)
@@ -187,18 +157,14 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
}
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
// Import to canvas
const handleImportToCanvas = useCallback(async () => {
if (!pipelineResult?.fabric_json || !canvas) return
try {
// Clear canvas and load new content
canvas.clear()
canvas.loadFromJSON(pipelineResult.fabric_json, () => {
canvas.renderAll()
saveToHistory('Imported: Cleaned worksheet')
})
onClose()
} catch (err) {
console.error('Import failed:', err)
@@ -206,23 +172,13 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
}
}, [pipelineResult, canvas, saveToHistory, onClose])
// Get detection mask
const handleGetMask = useCallback(async () => {
if (!file) return
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, { method: 'POST', body: formData })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const blob = await response.blob()
setMaskUrl(URL.createObjectURL(blob))
} catch (err) {
@@ -232,7 +188,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
if (!isOpen) return null
// Styles
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
const modalStyle = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
@@ -242,6 +197,9 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
? 'bg-white/5 border-white/10 hover:bg-white/10'
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
const stepNames = ['upload', 'preview', 'result'] as const
const stepIndex = stepNames.indexOf(currentStep)
return (
<div className={overlayStyle} onClick={onClose}>
<div
@@ -251,9 +209,7 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isDark ? 'bg-orange-500/20' : 'bg-orange-100'
}`}>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-orange-100'}`}>
<svg className={`w-7 h-7 ${isDark ? 'text-orange-300' : 'text-orange-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
@@ -262,17 +218,10 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Arbeitsblatt bereinigen
</h2>
<p className={`text-sm ${labelStyle}`}>
Handschrift entfernen und Layout rekonstruieren
</p>
<p className={`text-sm ${labelStyle}`}>Handschrift entfernen und Layout rekonstruieren</p>
</div>
</div>
<button
onClick={onClose}
className={`p-2 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
}`}
>
<button onClick={onClose} className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'}`}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -281,23 +230,19 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
{/* Step Indicator */}
<div className="flex items-center gap-2 mb-6">
{['upload', 'preview', 'result'].map((step, idx) => (
{stepNames.map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
currentStep === step
? isDark ? 'bg-purple-500 text-white' : 'bg-purple-600 text-white'
: idx < ['upload', 'preview', 'result'].indexOf(currentStep)
: idx < stepIndex
? isDark ? 'bg-green-500 text-white' : 'bg-green-600 text-white'
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-200 text-slate-400'
}`}>
{idx < ['upload', 'preview', 'result'].indexOf(currentStep) ? '✓' : idx + 1}
{idx < stepIndex ? '✓' : idx + 1}
</div>
{idx < 2 && (
<div className={`w-12 h-0.5 ${
idx < ['upload', 'preview', 'result'].indexOf(currentStep)
? isDark ? 'bg-green-500' : 'bg-green-600'
: isDark ? 'bg-white/20' : 'bg-slate-200'
}`} />
<div className={`w-12 h-0.5 ${idx < stepIndex ? isDark ? 'bg-green-500' : 'bg-green-600' : isDark ? 'bg-white/20' : 'bg-slate-200'}`} />
)}
</div>
))}
@@ -305,366 +250,40 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Error Display */}
{error && (
<div className={`p-4 rounded-xl mb-4 ${
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
}`}>
<div className={`p-4 rounded-xl mb-4 ${isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'}`}>
{error}
</div>
)}
{/* Step 1: Upload */}
{currentStep === 'upload' && (
<div className="space-y-6">
{/* Dropzone */}
<div
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
isDark
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
}`}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
className="hidden"
<UploadStep
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
file={file} previewUrl={previewUrl}
handleDrop={handleDrop} handleFileSelect={handleFileSelect}
removeHandwriting={removeHandwriting} setRemoveHandwriting={setRemoveHandwriting}
reconstructLayout={reconstructLayout} setReconstructLayout={setReconstructLayout}
inpaintingMethod={inpaintingMethod} setInpaintingMethod={setInpaintingMethod}
lamaAvailable={capabilities?.lama_available ?? false}
/>
{previewUrl ? (
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="max-h-64 mx-auto rounded-xl shadow-lg"
/>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{file?.name}
</p>
<p className={`text-sm ${labelStyle}`}>
Klicke zum Ändern oder ziehe eine andere Datei hierher
</p>
</div>
) : (
<>
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bild hochladen
</p>
<p className={labelStyle}>
Ziehe ein Bild hierher oder klicke zum Auswählen
</p>
<p className={`text-xs mt-2 ${labelStyle}`}>
Unterstützt: PNG, JPG, JPEG
</p>
</>
)}
</div>
{/* Options */}
{file && (
<div className="space-y-4">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Optionen
</h3>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={removeHandwriting}
onChange={(e) => setRemoveHandwriting(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Handschrift entfernen
</span>
<p className={`text-sm ${labelStyle}`}>
Erkennt und entfernt handgeschriebene Inhalte
</p>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={reconstructLayout}
onChange={(e) => setReconstructLayout(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout rekonstruieren
</span>
<p className={`text-sm ${labelStyle}`}>
Erstellt bearbeitbare Fabric.js Objekte
</p>
</div>
</label>
{removeHandwriting && (
<div className="space-y-2">
<label className={`block text-sm font-medium ${labelStyle}`}>
Inpainting-Methode
</label>
<select
value={inpaintingMethod}
onChange={(e) => setInpaintingMethod(e.target.value)}
className={`w-full p-3 rounded-xl border ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
>
<option value="auto">Automatisch (empfohlen)</option>
<option value="opencv_telea">OpenCV Telea (schnell)</option>
<option value="opencv_ns">OpenCV NS (glatter)</option>
{capabilities?.lama_available && (
<option value="lama">LaMa (beste Qualität)</option>
)}
</select>
</div>
)}
</div>
)}
</div>
)}
{/* Step 2: Preview */}
{currentStep === 'preview' && previewResult && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
{/* Detection Result */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Erkennungsergebnis
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Handschrift gefunden:</span>
<span className={previewResult.has_handwriting
? isDark ? 'text-orange-300' : 'text-orange-600'
: isDark ? 'text-green-300' : 'text-green-600'
}>
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Konfidenz:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Handschrift-Anteil:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Bildgröße:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{previewResult.image_width} × {previewResult.image_height}
</span>
</div>
</div>
</div>
{/* Time Estimates */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Geschätzte Zeit
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Erkennung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
</span>
</div>
{removeHandwriting && previewResult.has_handwriting && (
<div className="flex justify-between">
<span className={labelStyle}>Bereinigung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
</span>
</div>
)}
{reconstructLayout && (
<div className="flex justify-between">
<span className={labelStyle}>Layout:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
</span>
</div>
)}
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
</span>
</div>
</div>
</div>
</div>
{/* Preview Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Original
</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full rounded-xl shadow-lg"
<PreviewStep
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
previewResult={previewResult} previewUrl={previewUrl} maskUrl={maskUrl}
removeHandwriting={removeHandwriting} reconstructLayout={reconstructLayout}
handleGetMask={handleGetMask}
/>
)}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Maske
</h3>
<button
onClick={handleGetMask}
className={`text-sm px-3 py-1 rounded-lg ${
isDark
? 'bg-white/10 text-white/70 hover:bg-white/20'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Maske laden
</button>
</div>
{maskUrl ? (
<img
src={maskUrl}
alt="Mask"
className="w-full rounded-xl shadow-lg"
/>
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${
isDark ? 'bg-white/5' : 'bg-slate-100'
}`}>
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Step 3: Result */}
{currentStep === 'result' && pipelineResult && (
<div className="space-y-6">
{/* Status */}
<div className={`p-4 rounded-xl ${
pipelineResult.success
? isDark ? 'bg-green-500/20' : 'bg-green-50'
: isDark ? 'bg-red-500/20' : 'bg-red-50'
}`}>
<div className="flex items-center gap-3">
{pipelineResult.success ? (
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<div>
<h3 className={`font-medium ${
pipelineResult.success
? isDark ? 'text-green-300' : 'text-green-700'
: isDark ? 'text-red-300' : 'text-red-700'
}`}>
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
</h3>
<p className={`text-sm ${labelStyle}`}>
{pipelineResult.handwriting_detected
? pipelineResult.handwriting_removed
? 'Handschrift wurde erkannt und entfernt'
: 'Handschrift erkannt, aber nicht entfernt'
: 'Keine Handschrift gefunden'}
</p>
</div>
</div>
</div>
{/* Result Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Original
</h3>
{previewUrl && (
<img
src={previewUrl}
alt="Original"
className="w-full rounded-xl shadow-lg"
<ResultStep
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
pipelineResult={pipelineResult} previewUrl={previewUrl} cleanedUrl={cleanedUrl}
/>
)}
</div>
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bereinigt
</h3>
{cleanedUrl ? (
<img
src={cleanedUrl}
alt="Cleaned"
className="w-full rounded-xl shadow-lg"
/>
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${
isDark ? 'bg-white/5' : 'bg-slate-100'
}`}>
<span className={labelStyle}>Kein Bild</span>
</div>
)}
</div>
</div>
{/* Layout Info */}
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout-Rekonstruktion
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<span className={labelStyle}>Elemente:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.element_count}
</p>
</div>
<div>
<span className={labelStyle}>Tabellen:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.table_count}
</p>
</div>
<div>
<span className={labelStyle}>Größe:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="mt-6 flex items-center justify-between">
@@ -673,9 +292,7 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
<button
onClick={() => setCurrentStep(currentStep === 'result' ? 'preview' : 'upload')}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Zurück
@@ -684,73 +301,34 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
isDark
? 'bg-white/10 text-white hover:bg-white/20'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
<button onClick={onClose} className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}>
Abbrechen
</button>
{currentStep === 'upload' && file && (
<button
onClick={handlePreview}
disabled={isPreviewing}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
: 'bg-purple-600 text-white hover:bg-purple-700'
} disabled:opacity-50`}
>
{isPreviewing ? (
<>
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
Analysiere...
</>
) : (
'Vorschau'
)}
<button onClick={handlePreview} disabled={isPreviewing} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark ? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40' : 'bg-purple-600 text-white hover:bg-purple-700'
} disabled:opacity-50`}>
{isPreviewing ? (<><div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />Analysiere...</>) : 'Vorschau'}
</button>
)}
{currentStep === 'preview' && (
<button
onClick={handleCleanup}
disabled={isProcessing}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30'
: 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
} disabled:opacity-50`}
>
{isProcessing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Verarbeite...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Bereinigen
</>
<button onClick={handleCleanup} disabled={isProcessing} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark ? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30' : 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
} disabled:opacity-50`}>
{isProcessing ? (<><div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />Verarbeite...</>) : (
<><svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /></svg>Bereinigen</>
)}
</button>
)}
{currentStep === 'result' && pipelineResult?.success && (
<button
onClick={handleImportToCanvas}
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30'
: 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
}`}
>
<button onClick={handleImportToCanvas} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
isDark ? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30' : 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
@@ -0,0 +1,390 @@
'use client'
import type { PreviewResult, PipelineResult } from './CleanupPanel'
// =============================================================================
// Step 1: Upload
// =============================================================================
interface UploadStepProps {
isDark: boolean
labelStyle: string
cardStyle: string
file: File | null
previewUrl: string | null
handleDrop: (e: React.DragEvent) => void
handleFileSelect: (f: File) => void
removeHandwriting: boolean
setRemoveHandwriting: (v: boolean) => void
reconstructLayout: boolean
setReconstructLayout: (v: boolean) => void
inpaintingMethod: string
setInpaintingMethod: (v: string) => void
lamaAvailable: boolean
}
export function UploadStep({
isDark, labelStyle, cardStyle, file, previewUrl,
handleDrop, handleFileSelect,
removeHandwriting, setRemoveHandwriting,
reconstructLayout, setReconstructLayout,
inpaintingMethod, setInpaintingMethod,
lamaAvailable,
}: UploadStepProps) {
return (
<div className="space-y-6">
{/* Dropzone */}
<div
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
isDark
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
}`}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
className="hidden"
/>
{previewUrl ? (
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="max-h-64 mx-auto rounded-xl shadow-lg"
/>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{file?.name}
</p>
<p className={`text-sm ${labelStyle}`}>
Klicke zum Ändern oder ziehe eine andere Datei hierher
</p>
</div>
) : (
<>
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Bild hochladen
</p>
<p className={labelStyle}>
Ziehe ein Bild hierher oder klicke zum Auswählen
</p>
<p className={`text-xs mt-2 ${labelStyle}`}>
Unterstützt: PNG, JPG, JPEG
</p>
</>
)}
</div>
{/* Options */}
{file && (
<div className="space-y-4">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Optionen
</h3>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={removeHandwriting}
onChange={(e) => setRemoveHandwriting(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Handschrift entfernen
</span>
<p className={`text-sm ${labelStyle}`}>
Erkennt und entfernt handgeschriebene Inhalte
</p>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
<input
type="checkbox"
checked={reconstructLayout}
onChange={(e) => setReconstructLayout(e.target.checked)}
className="w-5 h-5 rounded"
/>
<div>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout rekonstruieren
</span>
<p className={`text-sm ${labelStyle}`}>
Erstellt bearbeitbare Fabric.js Objekte
</p>
</div>
</label>
{removeHandwriting && (
<div className="space-y-2">
<label className={`block text-sm font-medium ${labelStyle}`}>
Inpainting-Methode
</label>
<select
value={inpaintingMethod}
onChange={(e) => setInpaintingMethod(e.target.value)}
className={`w-full p-3 rounded-xl border ${
isDark
? 'bg-white/10 border-white/20 text-white'
: 'bg-white border-slate-200 text-slate-900'
}`}
>
<option value="auto">Automatisch (empfohlen)</option>
<option value="opencv_telea">OpenCV Telea (schnell)</option>
<option value="opencv_ns">OpenCV NS (glatter)</option>
{lamaAvailable && (
<option value="lama">LaMa (beste Qualität)</option>
)}
</select>
</div>
)}
</div>
)}
</div>
)
}
// =============================================================================
// Step 2: Preview
// =============================================================================
interface PreviewStepProps {
isDark: boolean
labelStyle: string
cardStyle: string
previewResult: PreviewResult
previewUrl: string | null
maskUrl: string | null
removeHandwriting: boolean
reconstructLayout: boolean
handleGetMask: () => void
}
export function PreviewStep({
isDark, labelStyle, cardStyle,
previewResult, previewUrl, maskUrl,
removeHandwriting, reconstructLayout,
handleGetMask,
}: PreviewStepProps) {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
{/* Detection Result */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Erkennungsergebnis
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Handschrift gefunden:</span>
<span className={previewResult.has_handwriting
? isDark ? 'text-orange-300' : 'text-orange-600'
: isDark ? 'text-green-300' : 'text-green-600'
}>
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Konfidenz:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Handschrift-Anteil:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className={labelStyle}>Bildgröße:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
{previewResult.image_width} × {previewResult.image_height}
</span>
</div>
</div>
</div>
{/* Time Estimates */}
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Geschätzte Zeit
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className={labelStyle}>Erkennung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
</span>
</div>
{removeHandwriting && previewResult.has_handwriting && (
<div className="flex justify-between">
<span className={labelStyle}>Bereinigung:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
</span>
</div>
)}
{reconstructLayout && (
<div className="flex justify-between">
<span className={labelStyle}>Layout:</span>
<span className={isDark ? 'text-white' : 'text-slate-900'}>
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
</span>
</div>
)}
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
</span>
</div>
</div>
</div>
</div>
{/* Preview Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Original
</h3>
{previewUrl && <img src={previewUrl} alt="Original" className="w-full rounded-xl shadow-lg" />}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Maske</h3>
<button
onClick={handleGetMask}
className={`text-sm px-3 py-1 rounded-lg ${
isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Maske laden
</button>
</div>
{maskUrl ? (
<img src={maskUrl} alt="Mask" className="w-full rounded-xl shadow-lg" />
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
</div>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// Step 3: Result
// =============================================================================
interface ResultStepProps {
isDark: boolean
labelStyle: string
cardStyle: string
pipelineResult: PipelineResult
previewUrl: string | null
cleanedUrl: string | null
}
export function ResultStep({
isDark, labelStyle, cardStyle,
pipelineResult, previewUrl, cleanedUrl,
}: ResultStepProps) {
return (
<div className="space-y-6">
{/* Status */}
<div className={`p-4 rounded-xl ${
pipelineResult.success
? isDark ? 'bg-green-500/20' : 'bg-green-50'
: isDark ? 'bg-red-500/20' : 'bg-red-50'
}`}>
<div className="flex items-center gap-3">
{pipelineResult.success ? (
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<div>
<h3 className={`font-medium ${
pipelineResult.success
? isDark ? 'text-green-300' : 'text-green-700'
: isDark ? 'text-red-300' : 'text-red-700'
}`}>
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
</h3>
<p className={`text-sm ${labelStyle}`}>
{pipelineResult.handwriting_detected
? pipelineResult.handwriting_removed
? 'Handschrift wurde erkannt und entfernt'
: 'Handschrift erkannt, aber nicht entfernt'
: 'Keine Handschrift gefunden'}
</p>
</div>
</div>
</div>
{/* Result Images */}
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>Original</h3>
{previewUrl && <img src={previewUrl} alt="Original" className="w-full rounded-xl shadow-lg" />}
</div>
<div>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>Bereinigt</h3>
{cleanedUrl ? (
<img src={cleanedUrl} alt="Cleaned" className="w-full rounded-xl shadow-lg" />
) : (
<div className={`aspect-video rounded-xl flex items-center justify-center ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
<span className={labelStyle}>Kein Bild</span>
</div>
)}
</div>
</div>
{/* Layout Info */}
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
<div className={`p-4 rounded-xl border ${cardStyle}`}>
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Layout-Rekonstruktion
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<span className={labelStyle}>Elemente:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.element_count}
</p>
</div>
<div>
<span className={labelStyle}>Tabellen:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.table_count}
</p>
</div>
<div>
<span className={labelStyle}>Größe:</span>
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
</p>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { COLORS } from './types'
function ColorSwatch({ color }: { color: { name: string; value: string; text: string } }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(color.value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copyToClipboard}
className="group flex flex-col items-center"
title={`Klicken zum Kopieren: ${color.value}`}
>
<div
className="w-16 h-16 rounded-lg shadow-sm border border-slate-200 transition-transform group-hover:scale-110 flex items-center justify-center"
style={{ backgroundColor: color.value }}
>
{copied && (
<svg className={`w-5 h-5 ${color.text === 'light' ? 'text-white' : 'text-slate-900'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs text-slate-600 mt-1">{color.name}</span>
<span className="text-xs text-slate-400">{color.value}</span>
</button>
)
}
export default function ColorsTab() {
return (
<div className="space-y-8">
{Object.entries(COLORS).map(([key, palette]) => (
<div key={key} className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">{palette.name}</h3>
<div className="flex gap-4 flex-wrap">
{palette.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} />
))}
</div>
</div>
))}
{/* Color Usage Guidelines */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Verwendungsrichtlinien</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-700 mb-2">Primary (Sky Blue)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Primäre Buttons und CTAs</li>
<li>- Links und interaktive Elemente</li>
<li>- Fokuszustände</li>
<li>- Ausgewählte Navigation</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-2">Accent (Fuchsia)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Highlights und Akzente</li>
<li>- Badges und Tags</li>
<li>- Gradient-Akzente</li>
<li>- Kreative Elemente</li>
</ul>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,105 @@
'use client'
export default function ComponentsTab() {
return (
<div className="space-y-6">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h3>
<div className="flex flex-wrap gap-4 mb-6">
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors">
Primary
</button>
<button className="px-6 py-2 bg-slate-100 text-slate-700 font-medium rounded-lg hover:bg-slate-200 transition-colors">
Secondary
</button>
<button className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors">
Outline
</button>
<button className="px-6 py-2 text-primary-600 font-medium rounded-lg hover:bg-primary-50 transition-colors">
Ghost
</button>
<button className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors">
Danger
</button>
</div>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-1.5 bg-primary-600 text-white text-sm font-medium rounded-lg">
Small
</button>
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg">
Medium
</button>
<button className="px-8 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg">
Large
</button>
</div>
</div>
{/* Inputs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Eingabefelder</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Standard Input</label>
<input
type="text"
placeholder="Placeholder..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mit Icon</label>
<div className="relative">
<input
type="text"
placeholder="Suchen..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<svg className="w-5 h-5 text-slate-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Cards</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-lg transition-shadow">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="font-semibold text-slate-900 mb-1">Feature Card</h4>
<p className="text-sm text-slate-600">Beschreibungstext für diese Feature-Karte.</p>
</div>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Highlight Card</h4>
<p className="text-sm text-primary-100">Mit Gradient-Hintergrund.</p>
</div>
<div className="bg-slate-900 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Dark Card</h4>
<p className="text-sm text-slate-400">Dunkler Hintergrund.</p>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Badges & Tags</h3>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded">Primary</span>
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded">Success</span>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded">Warning</span>
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">Danger</span>
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs font-medium rounded">Neutral</span>
<span className="px-2 py-1 bg-fuchsia-100 text-fuchsia-700 text-xs font-medium rounded">Accent</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,117 @@
'use client'
export default function LogoTab() {
return (
<div className="space-y-6">
{/* Logo Display */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo</h3>
<div className="grid grid-cols-2 gap-8">
<div className="bg-white border border-slate-200 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
<div className="bg-slate-900 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-400 to-primary-500 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-white">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Logo Variations */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h3>
<div className="grid grid-cols-4 gap-4">
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-xs text-slate-600">Icon Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<span className="text-xl font-bold text-slate-900">BreakPilot</span>
<span className="text-xs text-slate-600">Text Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Horizontal</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex flex-col items-center">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mb-1">
<span className="text-xl font-bold text-white">B</span>
</div>
<span className="text-sm font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Stacked</span>
</div>
</div>
</div>
{/* Clear Space */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h3>
<p className="text-sm text-slate-600 mb-4">
Um das Logo herum sollte mindestens ein Abstand von der Höhe des Icons eingehalten werden.
</p>
<div className="bg-slate-50 rounded-lg p-8 inline-block">
<div className="border-2 border-dashed border-slate-300 p-8">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Don'ts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Nicht erlaubt</h3>
<div className="grid grid-cols-3 gap-4">
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center opacity-50">
<div className="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-red-500">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Farben</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center" style={{ transform: 'skewX(-10deg)' }}>
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Verzerrt</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">Break Pilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Schreibweise</span>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
import { TYPOGRAPHY } from './types'
export default function TypographyTab() {
return (
<div className="space-y-6">
{/* Font Family */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm text-slate-600 mb-4">
font-family: {TYPOGRAPHY.fontFamily};
</div>
<p className="text-sm text-slate-600">
Inter ist eine moderne, variable Sans-Serif Schrift, optimiert für Bildschirme.
Sie ist unter der SIL Open Font License verfügbar und frei für kommerzielle Nutzung.
</p>
</div>
{/* Font Weights */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftschnitte</h3>
<div className="space-y-4">
{TYPOGRAPHY.weights.map((w) => (
<div key={w.weight} className="flex items-center gap-6">
<span
className="text-2xl w-48"
style={{ fontWeight: w.weight }}
>
The quick brown fox
</span>
<div className="flex-1">
<span className="font-medium text-slate-900">{w.name} ({w.weight})</span>
<span className="text-sm text-slate-500 ml-4">{w.usage}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Sizes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftgrößen</h3>
<div className="space-y-3">
{TYPOGRAPHY.sizes.map((s) => (
<div key={s.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-3">
<span className="w-16 text-sm font-mono text-slate-500">{s.name}</span>
<span className="w-32 text-sm text-slate-600">{s.size}</span>
<span className="text-sm text-slate-500">{s.usage}</span>
</div>
))}
</div>
</div>
{/* Headings Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Überschriften-Hierarchie</h3>
<div className="space-y-4">
<h1 className="text-4xl font-bold text-slate-900">H1: Hauptüberschrift</h1>
<h2 className="text-3xl font-bold text-slate-900">H2: Abschnittsüberschrift</h2>
<h3 className="text-2xl font-semibold text-slate-900">H3: Unterabschnitt</h3>
<h4 className="text-xl font-semibold text-slate-900">H4: Card-Titel</h4>
<h5 className="text-lg font-medium text-slate-900">H5: Kleiner Titel</h5>
<h6 className="text-base font-medium text-slate-900">H6: Label</h6>
</div>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
'use client'
import { VOICE_TONE } from './types'
export default function VoiceTab() {
return (
<div className="space-y-6">
{/* Brand Attributes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Markenpersönlichkeit</h3>
<div className="flex flex-wrap gap-3">
{VOICE_TONE.attributes.map((attr) => (
<span
key={attr}
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-full font-medium"
>
{attr}
</span>
))}
</div>
</div>
{/* Do & Don't */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-green-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
So schreiben wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.doList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Das vermeiden wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.dontList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
</div>
{/* Example Texts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Beispieltexte</h3>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-xs text-green-600 font-medium mb-2 block">GUT</span>
<p className="text-slate-700">
"Sparen Sie Zeit bei der Korrektur - unsere KI unterstützt Sie mit intelligenten Vorschlägen."
</p>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<span className="text-xs text-red-600 font-medium mb-2 block">SCHLECHT</span>
<p className="text-slate-700">
"Unsere revolutionäre KI-Lösung optimiert Ihre Korrekturworkflows durch state-of-the-art NLP."
</p>
</div>
</div>
</div>
{/* Target Audience */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zielgruppe</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👩🏫</div>
<h4 className="font-semibold text-slate-900">Lehrkräfte</h4>
<p className="text-sm text-slate-600">Wünschen Zeitersparnis und einfache Bedienung</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">🏫</div>
<h4 className="font-semibold text-slate-900">Schulleitung</h4>
<p className="text-sm text-slate-600">Fokus auf DSGVO, Kosten und Integration</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👨👩👧</div>
<h4 className="font-semibold text-slate-900">Eltern</h4>
<p className="text-sm text-slate-600">Wollen Transparenz und schnelles Feedback</p>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,115 @@
export const COLORS = {
primary: {
name: 'Primary (Sky Blue)',
shades: [
{ name: '50', value: '#f0f9ff', text: 'dark' },
{ name: '100', value: '#e0f2fe', text: 'dark' },
{ name: '200', value: '#bae6fd', text: 'dark' },
{ name: '300', value: '#7dd3fc', text: 'dark' },
{ name: '400', value: '#38bdf8', text: 'dark' },
{ name: '500', value: '#0ea5e9', text: 'light' },
{ name: '600', value: '#0284c7', text: 'light' },
{ name: '700', value: '#0369a1', text: 'light' },
{ name: '800', value: '#075985', text: 'light' },
{ name: '900', value: '#0c4a6e', text: 'light' },
],
},
accent: {
name: 'Accent (Fuchsia)',
shades: [
{ name: '50', value: '#fdf4ff', text: 'dark' },
{ name: '100', value: '#fae8ff', text: 'dark' },
{ name: '200', value: '#f5d0fe', text: 'dark' },
{ name: '300', value: '#f0abfc', text: 'dark' },
{ name: '400', value: '#e879f9', text: 'dark' },
{ name: '500', value: '#d946ef', text: 'light' },
{ name: '600', value: '#c026d3', text: 'light' },
{ name: '700', value: '#a21caf', text: 'light' },
{ name: '800', value: '#86198f', text: 'light' },
{ name: '900', value: '#701a75', text: 'light' },
],
},
success: {
name: 'Success (Emerald)',
shades: [
{ name: '500', value: '#10b981', text: 'light' },
{ name: '600', value: '#059669', text: 'light' },
],
},
warning: {
name: 'Warning (Amber)',
shades: [
{ name: '500', value: '#f59e0b', text: 'dark' },
{ name: '600', value: '#d97706', text: 'light' },
],
},
danger: {
name: 'Danger (Red)',
shades: [
{ name: '500', value: '#ef4444', text: 'light' },
{ name: '600', value: '#dc2626', text: 'light' },
],
},
neutral: {
name: 'Neutral (Slate)',
shades: [
{ name: '50', value: '#f8fafc', text: 'dark' },
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '200', value: '#e2e8f0', text: 'dark' },
{ name: '300', value: '#cbd5e1', text: 'dark' },
{ name: '400', value: '#94a3b8', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
{ name: '800', value: '#1e293b', text: 'light' },
{ name: '900', value: '#0f172a', text: 'light' },
],
},
}
export const TYPOGRAPHY = {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
weights: [
{ weight: 400, name: 'Regular', usage: 'Fließtext, Beschreibungen' },
{ weight: 500, name: 'Medium', usage: 'Labels, Buttons' },
{ weight: 600, name: 'Semi-Bold', usage: 'Überschriften H3-H6' },
{ weight: 700, name: 'Bold', usage: 'Überschriften H1-H2, CTAs' },
],
sizes: [
{ name: 'xs', size: '0.75rem (12px)', usage: 'Footnotes, Badges' },
{ name: 'sm', size: '0.875rem (14px)', usage: 'Nebentext, Labels' },
{ name: 'base', size: '1rem (16px)', usage: 'Fließtext, Body' },
{ name: 'lg', size: '1.125rem (18px)', usage: 'Lead Text' },
{ name: 'xl', size: '1.25rem (20px)', usage: 'H4, Card Titles' },
{ name: '2xl', size: '1.5rem (24px)', usage: 'H3' },
{ name: '3xl', size: '1.875rem (30px)', usage: 'H2' },
{ name: '4xl', size: '2.25rem (36px)', usage: 'H1, Hero' },
{ name: '5xl', size: '3rem (48px)', usage: 'Display' },
],
}
export const VOICE_TONE = {
attributes: [
'Professionell & Vertrauenswürdig',
'Freundlich & Zugänglich',
'Klar & Direkt',
'Hilfreich & Unterstützend',
'Modern & Innovativ',
],
doList: [
'Einfache Sprache verwenden',
'Aktiv formulieren',
'Nutzenorientiert schreiben',
'Konkrete Beispiele geben',
'Empathie zeigen',
],
dontList: [
'Fachjargon ohne Erklärung',
'Passive Formulierungen',
'Übertriebene Versprechen',
'Negative Formulierungen',
'Unpersönliche Ansprache',
],
}
export type BrandbookTab = 'colors' | 'typography' | 'components' | 'logo' | 'voice'
+20 -587
View File
@@ -13,185 +13,33 @@
import { useState } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import type { BrandbookTab } from './_components/types'
import ColorsTab from './_components/ColorsTab'
import TypographyTab from './_components/TypographyTab'
import ComponentsTab from './_components/ComponentsTab'
import LogoTab from './_components/LogoTab'
import VoiceTab from './_components/VoiceTab'
// Color palette from actual CSS variables
const COLORS = {
primary: {
name: 'Primary (Sky Blue)',
shades: [
{ name: '50', value: '#f0f9ff', text: 'dark' },
{ name: '100', value: '#e0f2fe', text: 'dark' },
{ name: '200', value: '#bae6fd', text: 'dark' },
{ name: '300', value: '#7dd3fc', text: 'dark' },
{ name: '400', value: '#38bdf8', text: 'dark' },
{ name: '500', value: '#0ea5e9', text: 'light' },
{ name: '600', value: '#0284c7', text: 'light' },
{ name: '700', value: '#0369a1', text: 'light' },
{ name: '800', value: '#075985', text: 'light' },
{ name: '900', value: '#0c4a6e', text: 'light' },
],
},
accent: {
name: 'Accent (Fuchsia)',
shades: [
{ name: '50', value: '#fdf4ff', text: 'dark' },
{ name: '100', value: '#fae8ff', text: 'dark' },
{ name: '200', value: '#f5d0fe', text: 'dark' },
{ name: '300', value: '#f0abfc', text: 'dark' },
{ name: '400', value: '#e879f9', text: 'dark' },
{ name: '500', value: '#d946ef', text: 'light' },
{ name: '600', value: '#c026d3', text: 'light' },
{ name: '700', value: '#a21caf', text: 'light' },
{ name: '800', value: '#86198f', text: 'light' },
{ name: '900', value: '#701a75', text: 'light' },
],
},
success: {
name: 'Success (Emerald)',
shades: [
{ name: '500', value: '#10b981', text: 'light' },
{ name: '600', value: '#059669', text: 'light' },
],
},
warning: {
name: 'Warning (Amber)',
shades: [
{ name: '500', value: '#f59e0b', text: 'dark' },
{ name: '600', value: '#d97706', text: 'light' },
],
},
danger: {
name: 'Danger (Red)',
shades: [
{ name: '500', value: '#ef4444', text: 'light' },
{ name: '600', value: '#dc2626', text: 'light' },
],
},
neutral: {
name: 'Neutral (Slate)',
shades: [
{ name: '50', value: '#f8fafc', text: 'dark' },
{ name: '100', value: '#f1f5f9', text: 'dark' },
{ name: '200', value: '#e2e8f0', text: 'dark' },
{ name: '300', value: '#cbd5e1', text: 'dark' },
{ name: '400', value: '#94a3b8', text: 'dark' },
{ name: '500', value: '#64748b', text: 'light' },
{ name: '600', value: '#475569', text: 'light' },
{ name: '700', value: '#334155', text: 'light' },
{ name: '800', value: '#1e293b', text: 'light' },
{ name: '900', value: '#0f172a', text: 'light' },
],
},
}
const TYPOGRAPHY = {
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
weights: [
{ weight: 400, name: 'Regular', usage: 'Fließtext, Beschreibungen' },
{ weight: 500, name: 'Medium', usage: 'Labels, Buttons' },
{ weight: 600, name: 'Semi-Bold', usage: 'Überschriften H3-H6' },
{ weight: 700, name: 'Bold', usage: 'Überschriften H1-H2, CTAs' },
],
sizes: [
{ name: 'xs', size: '0.75rem (12px)', usage: 'Footnotes, Badges' },
{ name: 'sm', size: '0.875rem (14px)', usage: 'Nebentext, Labels' },
{ name: 'base', size: '1rem (16px)', usage: 'Fließtext, Body' },
{ name: 'lg', size: '1.125rem (18px)', usage: 'Lead Text' },
{ name: 'xl', size: '1.25rem (20px)', usage: 'H4, Card Titles' },
{ name: '2xl', size: '1.5rem (24px)', usage: 'H3' },
{ name: '3xl', size: '1.875rem (30px)', usage: 'H2' },
{ name: '4xl', size: '2.25rem (36px)', usage: 'H1, Hero' },
{ name: '5xl', size: '3rem (48px)', usage: 'Display' },
],
}
const SPACING = [
{ name: '0', value: '0px' },
{ name: '1', value: '0.25rem (4px)' },
{ name: '2', value: '0.5rem (8px)' },
{ name: '3', value: '0.75rem (12px)' },
{ name: '4', value: '1rem (16px)' },
{ name: '5', value: '1.25rem (20px)' },
{ name: '6', value: '1.5rem (24px)' },
{ name: '8', value: '2rem (32px)' },
{ name: '10', value: '2.5rem (40px)' },
{ name: '12', value: '3rem (48px)' },
{ name: '16', value: '4rem (64px)' },
const TABS: { id: BrandbookTab; label: string }[] = [
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logo', label: 'Logo' },
{ id: 'voice', label: 'Tonalität' },
]
const VOICE_TONE = {
attributes: [
'Professionell & Vertrauenswürdig',
'Freundlich & Zugänglich',
'Klar & Direkt',
'Hilfreich & Unterstützend',
'Modern & Innovativ',
],
doList: [
'Einfache Sprache verwenden',
'Aktiv formulieren',
'Nutzenorientiert schreiben',
'Konkrete Beispiele geben',
'Empathie zeigen',
],
dontList: [
'Fachjargon ohne Erklärung',
'Passive Formulierungen',
'Übertriebene Versprechen',
'Negative Formulierungen',
'Unpersönliche Ansprache',
],
}
function ColorSwatch({ color, name }: { color: { name: string; value: string; text: string }; name: string }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(color.value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copyToClipboard}
className="group flex flex-col items-center"
title={`Klicken zum Kopieren: ${color.value}`}
>
<div
className="w-16 h-16 rounded-lg shadow-sm border border-slate-200 transition-transform group-hover:scale-110 flex items-center justify-center"
style={{ backgroundColor: color.value }}
>
{copied && (
<svg className={`w-5 h-5 ${color.text === 'light' ? 'text-white' : 'text-slate-900'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs text-slate-600 mt-1">{color.name}</span>
<span className="text-xs text-slate-400">{color.value}</span>
</button>
)
}
export default function BrandbookPage() {
const [activeTab, setActiveTab] = useState<'colors' | 'typography' | 'components' | 'logo' | 'voice'>('colors')
const [activeTab, setActiveTab] = useState<BrandbookTab>('colors')
return (
<AdminLayout title="Brandbook" description="Corporate Design & Styleguide">
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
{[
{ id: 'colors', label: 'Farben' },
{ id: 'typography', label: 'Typografie' },
{ id: 'components', label: 'Komponenten' },
{ id: 'logo', label: 'Logo' },
{ id: 'voice', label: 'Tonalität' },
].map((tab) => (
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-slate-900 shadow-sm'
@@ -204,426 +52,11 @@ export default function BrandbookPage() {
</div>
</div>
{/* Colors Tab */}
{activeTab === 'colors' && (
<div className="space-y-8">
{Object.entries(COLORS).map(([key, palette]) => (
<div key={key} className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">{palette.name}</h3>
<div className="flex gap-4 flex-wrap">
{palette.shades.map((shade) => (
<ColorSwatch key={shade.name} color={shade} name={shade.name} />
))}
</div>
</div>
))}
{/* Color Usage Guidelines */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Verwendungsrichtlinien</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-slate-700 mb-2">Primary (Sky Blue)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Primäre Buttons und CTAs</li>
<li>- Links und interaktive Elemente</li>
<li>- Fokuszustände</li>
<li>- Ausgewählte Navigation</li>
</ul>
</div>
<div>
<h4 className="font-medium text-slate-700 mb-2">Accent (Fuchsia)</h4>
<ul className="text-sm text-slate-600 space-y-1">
<li>- Highlights und Akzente</li>
<li>- Badges und Tags</li>
<li>- Gradient-Akzente</li>
<li>- Kreative Elemente</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* Typography Tab */}
{activeTab === 'typography' && (
<div className="space-y-6">
{/* Font Family */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h3>
<div className="bg-slate-50 rounded-lg p-4 font-mono text-sm text-slate-600 mb-4">
font-family: {TYPOGRAPHY.fontFamily};
</div>
<p className="text-sm text-slate-600">
Inter ist eine moderne, variable Sans-Serif Schrift, optimiert für Bildschirme.
Sie ist unter der SIL Open Font License verfügbar und frei für kommerzielle Nutzung.
</p>
</div>
{/* Font Weights */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftschnitte</h3>
<div className="space-y-4">
{TYPOGRAPHY.weights.map((w) => (
<div key={w.weight} className="flex items-center gap-6">
<span
className="text-2xl w-48"
style={{ fontWeight: w.weight }}
>
The quick brown fox
</span>
<div className="flex-1">
<span className="font-medium text-slate-900">{w.name} ({w.weight})</span>
<span className="text-sm text-slate-500 ml-4">{w.usage}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Sizes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schriftgrößen</h3>
<div className="space-y-3">
{TYPOGRAPHY.sizes.map((s) => (
<div key={s.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-3">
<span className="w-16 text-sm font-mono text-slate-500">{s.name}</span>
<span className="w-32 text-sm text-slate-600">{s.size}</span>
<span className="text-sm text-slate-500">{s.usage}</span>
</div>
))}
</div>
</div>
{/* Headings Preview */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Überschriften-Hierarchie</h3>
<div className="space-y-4">
<h1 className="text-4xl font-bold text-slate-900">H1: Hauptüberschrift</h1>
<h2 className="text-3xl font-bold text-slate-900">H2: Abschnittsüberschrift</h2>
<h3 className="text-2xl font-semibold text-slate-900">H3: Unterabschnitt</h3>
<h4 className="text-xl font-semibold text-slate-900">H4: Card-Titel</h4>
<h5 className="text-lg font-medium text-slate-900">H5: Kleiner Titel</h5>
<h6 className="text-base font-medium text-slate-900">H6: Label</h6>
</div>
</div>
</div>
)}
{/* Components Tab */}
{activeTab === 'components' && (
<div className="space-y-6">
{/* Buttons */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h3>
<div className="flex flex-wrap gap-4 mb-6">
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 transition-colors">
Primary
</button>
<button className="px-6 py-2 bg-slate-100 text-slate-700 font-medium rounded-lg hover:bg-slate-200 transition-colors">
Secondary
</button>
<button className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors">
Outline
</button>
<button className="px-6 py-2 text-primary-600 font-medium rounded-lg hover:bg-primary-50 transition-colors">
Ghost
</button>
<button className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors">
Danger
</button>
</div>
<div className="flex flex-wrap gap-4">
<button className="px-4 py-1.5 bg-primary-600 text-white text-sm font-medium rounded-lg">
Small
</button>
<button className="px-6 py-2 bg-primary-600 text-white font-medium rounded-lg">
Medium
</button>
<button className="px-8 py-3 bg-primary-600 text-white text-lg font-medium rounded-lg">
Large
</button>
</div>
</div>
{/* Inputs */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Eingabefelder</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Standard Input</label>
<input
type="text"
placeholder="Placeholder..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mit Icon</label>
<div className="relative">
<input
type="text"
placeholder="Suchen..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<svg className="w-5 h-5 text-slate-400 absolute left-3 top-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
</div>
{/* Cards */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Cards</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 hover:shadow-lg transition-shadow">
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="font-semibold text-slate-900 mb-1">Feature Card</h4>
<p className="text-sm text-slate-600">Beschreibungstext für diese Feature-Karte.</p>
</div>
<div className="bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Highlight Card</h4>
<p className="text-sm text-primary-100">Mit Gradient-Hintergrund.</p>
</div>
<div className="bg-slate-900 rounded-xl p-4 text-white">
<h4 className="font-semibold mb-1">Dark Card</h4>
<p className="text-sm text-slate-400">Dunkler Hintergrund.</p>
</div>
</div>
</div>
{/* Badges */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Badges & Tags</h3>
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded">Primary</span>
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded">Success</span>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-medium rounded">Warning</span>
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded">Danger</span>
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs font-medium rounded">Neutral</span>
<span className="px-2 py-1 bg-fuchsia-100 text-fuchsia-700 text-xs font-medium rounded">Accent</span>
</div>
</div>
</div>
)}
{/* Logo Tab */}
{activeTab === 'logo' && (
<div className="space-y-6">
{/* Logo Display */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo</h3>
<div className="grid grid-cols-2 gap-8">
<div className="bg-white border border-slate-200 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
<div className="bg-slate-900 rounded-lg p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-400 to-primary-500 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-white">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Logo Variations */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h3>
<div className="grid grid-cols-4 gap-4">
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-xs text-slate-600">Icon Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<span className="text-xl font-bold text-slate-900">BreakPilot</span>
<span className="text-xs text-slate-600">Text Only</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Horizontal</span>
</div>
<div className="border border-slate-200 rounded-lg p-4 flex flex-col items-center gap-2">
<div className="flex flex-col items-center">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center mb-1">
<span className="text-xl font-bold text-white">B</span>
</div>
<span className="text-sm font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-slate-600">Stacked</span>
</div>
</div>
</div>
{/* Clear Space */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h3>
<p className="text-sm text-slate-600 mb-4">
Um das Logo herum sollte mindestens ein Abstand von der Höhe des Icons eingehalten werden.
</p>
<div className="bg-slate-50 rounded-lg p-8 inline-block">
<div className="border-2 border-dashed border-slate-300 p-8">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center">
<span className="text-2xl font-bold text-white">B</span>
</div>
<span className="text-2xl font-bold text-slate-900">BreakPilot</span>
</div>
</div>
</div>
</div>
{/* Don'ts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Nicht erlaubt</h3>
<div className="grid grid-cols-3 gap-4">
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center opacity-50">
<div className="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-red-500">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Farben</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center" style={{ transform: 'skewX(-10deg)' }}>
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">BreakPilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Verzerrt</span>
</div>
<div className="border border-red-200 bg-red-50 rounded-lg p-4 text-center">
<div className="flex items-center gap-2 justify-center">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-600 rounded-lg flex items-center justify-center">
<span className="text-lg font-bold text-white">B</span>
</div>
<span className="text-lg font-bold text-slate-900">Break Pilot</span>
</div>
<span className="text-xs text-red-600 mt-2 block">Falsche Schreibweise</span>
</div>
</div>
</div>
</div>
)}
{/* Voice & Tone Tab */}
{activeTab === 'voice' && (
<div className="space-y-6">
{/* Brand Attributes */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Markenpersönlichkeit</h3>
<div className="flex flex-wrap gap-3">
{VOICE_TONE.attributes.map((attr) => (
<span
key={attr}
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-full font-medium"
>
{attr}
</span>
))}
</div>
</div>
{/* Do & Don't */}
<div className="grid grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-green-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
So schreiben wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.doList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Das vermeiden wir
</h3>
<ul className="space-y-2">
{VOICE_TONE.dontList.map((item) => (
<li key={item} className="flex items-center gap-2 text-slate-600">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
{item}
</li>
))}
</ul>
</div>
</div>
{/* Example Texts */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Beispieltexte</h3>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<span className="text-xs text-green-600 font-medium mb-2 block">GUT</span>
<p className="text-slate-700">
"Sparen Sie Zeit bei der Korrektur - unsere KI unterstützt Sie mit intelligenten Vorschlägen."
</p>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<span className="text-xs text-red-600 font-medium mb-2 block">SCHLECHT</span>
<p className="text-slate-700">
"Unsere revolutionäre KI-Lösung optimiert Ihre Korrekturworkflows durch state-of-the-art NLP."
</p>
</div>
</div>
</div>
{/* Target Audience */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zielgruppe</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👩🏫</div>
<h4 className="font-semibold text-slate-900">Lehrkräfte</h4>
<p className="text-sm text-slate-600">Wünschen Zeitersparnis und einfache Bedienung</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">🏫</div>
<h4 className="font-semibold text-slate-900">Schulleitung</h4>
<p className="text-sm text-slate-600">Fokus auf DSGVO, Kosten und Integration</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-2xl mb-2">👨👩👧</div>
<h4 className="font-semibold text-slate-900">Eltern</h4>
<p className="text-sm text-slate-600">Wollen Transparenz und schnelles Feedback</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'colors' && <ColorsTab />}
{activeTab === 'typography' && <TypographyTab />}
{activeTab === 'components' && <ComponentsTab />}
{activeTab === 'logo' && <LogoTab />}
{activeTab === 'voice' && <VoiceTab />}
</AdminLayout>
)
}
@@ -0,0 +1,51 @@
'use client'
import type { Export } from './types'
import { formatFileSize } from './types'
interface ExportHistoryProps {
exports: Export[]
downloadExport: (id: string) => void
}
export default function ExportHistory({ exports, downloadExport }: ExportHistoryProps) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Exports</h3>
{exports.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Exports vorhanden</p>
) : (
<div className="space-y-3">
{exports.slice(0, 10).map((exp) => (
<div key={exp.id} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
exp.status === 'completed' ? 'bg-green-100 text-green-700' :
exp.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{exp.status}
</span>
<span className="text-xs text-slate-500">
{new Date(exp.requested_at).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-sm font-medium text-slate-900">{exp.export_name}</p>
<p className="text-xs text-slate-500">{exp.export_type} - {formatFileSize(exp.file_size_bytes)}</p>
{exp.status === 'completed' && (
<button
onClick={() => downloadExport(exp.id)}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Download
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,287 @@
'use client'
import type { Export, Regulation } from './types'
import { EXPORT_TYPES, DOMAIN_OPTIONS, formatFileSize } from './types'
interface ExportWizardProps {
wizardStep: number
setWizardStep: (step: number) => void
exportType: string
setExportType: (type: string) => void
regulations: Regulation[]
selectedRegulations: string[]
toggleRegulation: (code: string) => void
selectedDomains: string[]
toggleDomain: (domain: string) => void
generating: boolean
startExport: () => void
currentExport: Export | null
resetWizard: () => void
downloadExport: (id: string) => void
}
export default function ExportWizard(props: ExportWizardProps) {
return (
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
<WizardSteps current={props.wizardStep} />
{props.wizardStep === 1 && <Step1 exportType={props.exportType} setExportType={props.setExportType} next={() => props.setWizardStep(2)} />}
{props.wizardStep === 2 && <Step2 {...props} />}
{props.wizardStep === 3 && <Step3 {...props} />}
{props.wizardStep === 4 && <Step4 currentExport={props.currentExport} resetWizard={props.resetWizard} downloadExport={props.downloadExport} />}
</div>
)
}
function WizardSteps({ current }: { current: number }) {
const steps = [
{ num: 1, label: 'Typ' },
{ num: 2, label: 'Scope' },
{ num: 3, label: 'Bestaetigen' },
{ num: 4, label: 'Download' },
]
return (
<div className="flex items-center justify-center mb-8">
{steps.map((step, idx) => (
<div key={step.num} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full font-medium ${
current >= step.num ? 'bg-primary-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{current > step.num ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
step.num
)}
</div>
<span className={`ml-2 text-sm ${current >= step.num ? 'text-slate-900' : 'text-slate-500'}`}>
{step.label}
</span>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-4 ${current > step.num ? 'bg-primary-600' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
}
function Step1({ exportType, setExportType, next }: { exportType: string; setExportType: (t: string) => void; next: () => void }) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export-Typ waehlen</h3>
<div className="grid gap-4">
{EXPORT_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setExportType(type.value)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
exportType === type.value ? 'border-primary-600 bg-primary-50' : 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
exportType === type.value ? 'border-primary-600' : 'border-slate-300'
}`}>
{exportType === type.value && <div className="w-3 h-3 rounded-full bg-primary-600" />}
</div>
<div>
<p className="font-medium text-slate-900">{type.label}</p>
<p className="text-sm text-slate-500">{type.description}</p>
</div>
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<button onClick={next} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Weiter
</button>
</div>
</div>
)
}
function Step2(props: ExportWizardProps) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Scope definieren (optional)</h3>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Verordnungen filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Verordnungen</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{props.regulations.map((reg) => (
<button
key={reg.code}
onClick={() => props.toggleRegulation(reg.code)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
props.selectedRegulations.includes(reg.code)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{reg.code}
</button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Domains filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Domains</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DOMAIN_OPTIONS.map((domain) => (
<button
key={domain.value}
onClick={() => props.toggleDomain(domain.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
props.selectedDomains.includes(domain.value)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{domain.label}
</button>
))}
</div>
</div>
<div className="flex justify-between pt-4">
<button onClick={() => props.setWizardStep(1)} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Zurueck
</button>
<button onClick={() => props.setWizardStep(3)} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Weiter
</button>
</div>
</div>
)
}
function Step3(props: ExportWizardProps) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Export bestaetigen</h3>
<div className="bg-slate-50 rounded-lg p-6 space-y-4">
<div className="flex justify-between">
<span className="text-slate-600">Export-Typ:</span>
<span className="font-medium text-slate-900">
{EXPORT_TYPES.find((t) => t.value === props.exportType)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Verordnungen:</span>
<span className="font-medium text-slate-900">
{props.selectedRegulations.length > 0 ? props.selectedRegulations.join(', ') : 'Alle'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Domains:</span>
<span className="font-medium text-slate-900">
{props.selectedDomains.length > 0
? props.selectedDomains.map((d) => DOMAIN_OPTIONS.find((o) => o.value === d)?.label).join(', ')
: 'Alle'}
</span>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Der Export kann je nach Datenmenge einige Sekunden dauern.
Nach Abschluss koennen Sie die ZIP-Datei herunterladen.
</p>
</div>
<div className="flex justify-between pt-4">
<button onClick={() => props.setWizardStep(2)} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Zurueck
</button>
<button
onClick={props.startExport}
disabled={props.generating}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{props.generating && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{props.generating ? 'Generiere...' : 'Export starten'}
</button>
</div>
</div>
)
}
function Step4({ currentExport, resetWizard, downloadExport }: { currentExport: Export | null; resetWizard: () => void; downloadExport: (id: string) => void }) {
if (currentExport?.status === 'completed') {
return (
<div className="space-y-6 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export erfolgreich!</h3>
<div className="bg-slate-50 rounded-lg p-6 text-left space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Compliance Score:</span>
<span className="font-medium text-slate-900">{currentExport.compliance_score?.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Controls:</span>
<span className="font-medium text-slate-900">{currentExport.total_controls}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Nachweise:</span>
<span className="font-medium text-slate-900">{currentExport.total_evidence}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Dateigroesse:</span>
<span className="font-medium text-slate-900">{formatFileSize(currentExport.file_size_bytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">SHA-256:</span>
<span className="font-mono text-xs text-slate-500 truncate max-w-xs">{currentExport.file_hash}</span>
</div>
</div>
<div className="flex justify-center gap-4 pt-4">
<button onClick={resetWizard} className="px-6 py-2 text-slate-600 hover:text-slate-800">
Neuer Export
</button>
<button
onClick={() => downloadExport(currentExport.id)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
ZIP herunterladen
</button>
</div>
</div>
)
}
return (
<div className="space-y-6 text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export fehlgeschlagen</h3>
<p className="text-slate-500">{currentExport?.error_message || 'Unbekannter Fehler'}</p>
<button onClick={resetWizard} className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
Erneut versuchen
</button>
</div>
)
}
@@ -0,0 +1,46 @@
export interface Export {
id: string
export_type: string
export_name: string
status: string
requested_by: string
requested_at: string
completed_at: string | null
file_path: string | null
file_hash: string | null
file_size_bytes: number | null
total_controls: number | null
total_evidence: number | null
compliance_score: number | null
error_message: string | null
}
export interface Regulation {
code: string
name: string
}
export const EXPORT_TYPES = [
{ value: 'full', label: 'Vollstaendiger Export', description: 'Alle Daten inkl. Regulations, Controls, Evidence, Risks' },
{ value: 'controls_only', label: 'Nur Controls', description: 'Control Catalogue mit Mappings' },
{ value: 'evidence_only', label: 'Nur Nachweise', description: 'Evidence-Dateien und Metadaten' },
]
export const DOMAIN_OPTIONS = [
{ value: 'gov', label: 'Governance' },
{ value: 'priv', label: 'Datenschutz' },
{ value: 'iam', label: 'Identity & Access' },
{ value: 'crypto', label: 'Kryptografie' },
{ value: 'sdlc', label: 'Secure Dev' },
{ value: 'ops', label: 'Operations' },
{ value: 'ai', label: 'KI-spezifisch' },
{ value: 'cra', label: 'Supply Chain' },
{ value: 'aud', label: 'Audit' },
]
export function formatFileSize(bytes: number | null): string {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect } from 'react'
import type { Export, Regulation } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useExport() {
const [exports, setExports] = useState<Export[]>([])
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [exportType, setExportType] = useState('full')
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [currentExport, setCurrentExport] = useState<Export | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [exportsRes, regulationsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/exports`),
fetch(`${BACKEND_URL}/api/v1/compliance/regulations`),
])
if (exportsRes.ok) {
const data = await exportsRes.json()
setExports(data.exports || [])
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const startExport = async () => {
setGenerating(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_type: exportType,
included_regulations: selectedRegulations.length > 0 ? selectedRegulations : null,
included_domains: selectedDomains.length > 0 ? selectedDomains : null,
}),
})
if (res.ok) {
const exportData = await res.json()
setCurrentExport(exportData)
setWizardStep(4)
loadData()
} else {
const error = await res.text()
alert(`Export fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen')
} finally {
setGenerating(false)
}
}
const downloadExport = (exportId: string) => {
window.open(`${BACKEND_URL}/api/v1/compliance/export/${exportId}/download`, '_blank')
}
const resetWizard = () => {
setWizardStep(1)
setExportType('full')
setSelectedRegulations([])
setSelectedDomains([])
setCurrentExport(null)
}
const toggleRegulation = (code: string) => {
setSelectedRegulations((prev) =>
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code]
)
}
const toggleDomain = (domain: string) => {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
)
}
return {
exports, regulations, loading, generating,
wizardStep, setWizardStep,
exportType, setExportType,
selectedRegulations, selectedDomains,
currentExport,
startExport, downloadExport, resetWizard,
toggleRegulation, toggleDomain,
}
}
+25 -459
View File
@@ -11,420 +11,14 @@
* - Export history
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Export {
id: string
export_type: string
export_name: string
status: string
requested_by: string
requested_at: string
completed_at: string | null
file_path: string | null
file_hash: string | null
file_size_bytes: number | null
total_controls: number | null
total_evidence: number | null
compliance_score: number | null
error_message: string | null
}
interface Regulation {
code: string
name: string
}
const EXPORT_TYPES = [
{ value: 'full', label: 'Vollstaendiger Export', description: 'Alle Daten inkl. Regulations, Controls, Evidence, Risks' },
{ value: 'controls_only', label: 'Nur Controls', description: 'Control Catalogue mit Mappings' },
{ value: 'evidence_only', label: 'Nur Nachweise', description: 'Evidence-Dateien und Metadaten' },
]
const DOMAIN_OPTIONS = [
{ value: 'gov', label: 'Governance' },
{ value: 'priv', label: 'Datenschutz' },
{ value: 'iam', label: 'Identity & Access' },
{ value: 'crypto', label: 'Kryptografie' },
{ value: 'sdlc', label: 'Secure Dev' },
{ value: 'ops', label: 'Operations' },
{ value: 'ai', label: 'KI-spezifisch' },
{ value: 'cra', label: 'Supply Chain' },
{ value: 'aud', label: 'Audit' },
]
import { useExport } from './_components/useExport'
import ExportWizard from './_components/ExportWizard'
import ExportHistory from './_components/ExportHistory'
export default function ExportPage() {
const [exports, setExports] = useState<Export[]>([])
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [exportType, setExportType] = useState('full')
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [currentExport, setCurrentExport] = useState<Export | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [exportsRes, regulationsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/exports`),
fetch(`${BACKEND_URL}/api/v1/compliance/regulations`),
])
if (exportsRes.ok) {
const data = await exportsRes.json()
setExports(data.exports || [])
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const startExport = async () => {
setGenerating(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_type: exportType,
included_regulations: selectedRegulations.length > 0 ? selectedRegulations : null,
included_domains: selectedDomains.length > 0 ? selectedDomains : null,
}),
})
if (res.ok) {
const exportData = await res.json()
setCurrentExport(exportData)
setWizardStep(4)
loadData()
} else {
const error = await res.text()
alert(`Export fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen')
} finally {
setGenerating(false)
}
}
const downloadExport = (exportId: string) => {
window.open(`${BACKEND_URL}/api/v1/compliance/export/${exportId}/download`, '_blank')
}
const resetWizard = () => {
setWizardStep(1)
setExportType('full')
setSelectedRegulations([])
setSelectedDomains([])
setCurrentExport(null)
}
const toggleRegulation = (code: string) => {
setSelectedRegulations((prev) =>
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code]
)
}
const toggleDomain = (domain: string) => {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const renderWizardSteps = () => (
<div className="flex items-center justify-center mb-8">
{[
{ num: 1, label: 'Typ' },
{ num: 2, label: 'Scope' },
{ num: 3, label: 'Bestaetigen' },
{ num: 4, label: 'Download' },
].map((step, idx) => (
<div key={step.num} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full font-medium ${
wizardStep >= step.num
? 'bg-primary-600 text-white'
: 'bg-slate-200 text-slate-500'
}`}>
{wizardStep > step.num ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
step.num
)}
</div>
<span className={`ml-2 text-sm ${wizardStep >= step.num ? 'text-slate-900' : 'text-slate-500'}`}>
{step.label}
</span>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-4 ${wizardStep > step.num ? 'bg-primary-600' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
const renderStep1 = () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export-Typ waehlen</h3>
<div className="grid gap-4">
{EXPORT_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setExportType(type.value)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
exportType === type.value
? 'border-primary-600 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
exportType === type.value ? 'border-primary-600' : 'border-slate-300'
}`}>
{exportType === type.value && (
<div className="w-3 h-3 rounded-full bg-primary-600" />
)}
</div>
<div>
<p className="font-medium text-slate-900">{type.label}</p>
<p className="text-sm text-slate-500">{type.description}</p>
</div>
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep2 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Scope definieren (optional)</h3>
{/* Regulations Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Verordnungen filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Verordnungen</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{regulations.map((reg) => (
<button
key={reg.code}
onClick={() => toggleRegulation(reg.code)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedRegulations.includes(reg.code)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{reg.code}
</button>
))}
</div>
</div>
{/* Domains Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Domains filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Domains</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DOMAIN_OPTIONS.map((domain) => (
<button
key={domain.value}
onClick={() => toggleDomain(domain.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedDomains.includes(domain.value)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{domain.label}
</button>
))}
</div>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(1)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={() => setWizardStep(3)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep3 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Export bestaetigen</h3>
<div className="bg-slate-50 rounded-lg p-6 space-y-4">
<div className="flex justify-between">
<span className="text-slate-600">Export-Typ:</span>
<span className="font-medium text-slate-900">
{EXPORT_TYPES.find((t) => t.value === exportType)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Verordnungen:</span>
<span className="font-medium text-slate-900">
{selectedRegulations.length > 0 ? selectedRegulations.join(', ') : 'Alle'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Domains:</span>
<span className="font-medium text-slate-900">
{selectedDomains.length > 0
? selectedDomains.map((d) => DOMAIN_OPTIONS.find((o) => o.value === d)?.label).join(', ')
: 'Alle'}
</span>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Der Export kann je nach Datenmenge einige Sekunden dauern.
Nach Abschluss koennen Sie die ZIP-Datei herunterladen.
</p>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={startExport}
disabled={generating}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{generating && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{generating ? 'Generiere...' : 'Export starten'}
</button>
</div>
</div>
)
const renderStep4 = () => (
<div className="space-y-6 text-center">
{currentExport?.status === 'completed' ? (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export erfolgreich!</h3>
<div className="bg-slate-50 rounded-lg p-6 text-left space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Compliance Score:</span>
<span className="font-medium text-slate-900">{currentExport.compliance_score?.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Controls:</span>
<span className="font-medium text-slate-900">{currentExport.total_controls}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Nachweise:</span>
<span className="font-medium text-slate-900">{currentExport.total_evidence}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Dateigroesse:</span>
<span className="font-medium text-slate-900">{formatFileSize(currentExport.file_size_bytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">SHA-256:</span>
<span className="font-mono text-xs text-slate-500 truncate max-w-xs">{currentExport.file_hash}</span>
</div>
</div>
<div className="flex justify-center gap-4 pt-4">
<button
onClick={resetWizard}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Neuer Export
</button>
<button
onClick={() => downloadExport(currentExport.id)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
ZIP herunterladen
</button>
</div>
</>
) : (
<>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export fehlgeschlagen</h3>
<p className="text-slate-500">{currentExport?.error_message || 'Unbekannter Fehler'}</p>
<button
onClick={resetWizard}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erneut versuchen
</button>
</>
)}
</div>
)
const exp = useExport()
return (
<AdminLayout title="Audit Export" description="Export fuer externe Pruefer">
@@ -441,60 +35,32 @@ export default function ExportPage() {
</Link>
</div>
{loading ? (
{exp.loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Wizard */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
{renderWizardSteps()}
{wizardStep === 1 && renderStep1()}
{wizardStep === 2 && renderStep2()}
{wizardStep === 3 && renderStep3()}
{wizardStep === 4 && renderStep4()}
</div>
{/* Export History */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Exports</h3>
{exports.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Exports vorhanden</p>
) : (
<div className="space-y-3">
{exports.slice(0, 10).map((exp) => (
<div key={exp.id} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
exp.status === 'completed' ? 'bg-green-100 text-green-700' :
exp.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{exp.status}
</span>
<span className="text-xs text-slate-500">
{new Date(exp.requested_at).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-sm font-medium text-slate-900">{exp.export_name}</p>
<p className="text-xs text-slate-500">{exp.export_type} - {formatFileSize(exp.file_size_bytes)}</p>
{exp.status === 'completed' && (
<button
onClick={() => downloadExport(exp.id)}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Download
</button>
)}
</div>
))}
</div>
)}
</div>
<ExportWizard
wizardStep={exp.wizardStep}
setWizardStep={exp.setWizardStep}
exportType={exp.exportType}
setExportType={exp.setExportType}
regulations={exp.regulations}
selectedRegulations={exp.selectedRegulations}
toggleRegulation={exp.toggleRegulation}
selectedDomains={exp.selectedDomains}
toggleDomain={exp.toggleDomain}
generating={exp.generating}
startExport={exp.startExport}
currentExport={exp.currentExport}
resetWizard={exp.resetWizard}
downloadExport={exp.downloadExport}
/>
<ExportHistory
exports={exp.exports}
downloadExport={exp.downloadExport}
/>
</div>
)}
</AdminLayout>
@@ -0,0 +1,141 @@
'use client'
import type { RiskFormData } from './types'
import { RISK_COLORS, CATEGORY_OPTIONS, STATUS_OPTIONS, calculateRiskLevel } from './types'
interface RiskFormProps {
formData: RiskFormData
setFormData: (data: RiskFormData) => void
isCreate: boolean
}
export default function RiskForm({ formData, setFormData, isCreate }: RiskFormProps) {
return (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import type { Risk } from './types'
import { RISK_COLORS, CATEGORY_OPTIONS } from './types'
interface RiskListProps {
risks: Risk[]
onEditRisk: (risk: Risk) => void
}
export default function RiskList({ risks, onEditRisk }: RiskListProps) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onEditRisk(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,98 @@
'use client'
import type { Risk } from './types'
import { RISK_COLORS, RISK_BG_COLORS, calculateRiskLevel } from './types'
interface RiskMatrixProps {
risks: Risk[]
onEditRisk: (risk: Risk) => void
}
export default function RiskMatrix({ risks, onEditRisk }: RiskMatrixProps) {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => onEditRisk(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,65 @@
export interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
export const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
export const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
export const STATUS_OPTIONS = ['open', 'mitigated', 'accepted', 'transferred']
export const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
export interface RiskFormData {
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
owner: string
treatment_plan: string
status: string
mitigating_controls: string[]
residual_likelihood: number | null
residual_impact: number | null
}
@@ -0,0 +1,163 @@
'use client'
import { useState, useEffect } from 'react'
import type { Risk, RiskFormData } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useRisks() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [formData, setFormData] = useState<RiskFormData>({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
}
}
return {
risks, loading,
viewMode, setViewMode,
selectedRisk,
editModalOpen, setEditModalOpen,
createModalOpen, setCreateModalOpen,
formData, setFormData,
openCreateModal, openEditModal,
handleCreate, handleUpdate,
}
}
+25 -506
View File
@@ -9,496 +9,15 @@
* - Risk assessment / update
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
const STATUS_OPTIONS = ['open', 'mitigated', 'accepted', 'transferred']
const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
import { useRisks } from './_components/useRisks'
import RiskMatrix from './_components/RiskMatrix'
import RiskList from './_components/RiskList'
import RiskForm from './_components/RiskForm'
export default function RisksPage() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [formData, setFormData] = useState({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [] as string[],
residual_likelihood: null as number | null,
residual_impact: null as number | null,
})
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
}
}
// Build matrix data structure
const buildMatrix = () => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return matrix
}
const renderMatrix = () => {
const matrix = buildMatrix()
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => openEditModal(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
const renderList = () => (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEditModal(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
const renderForm = (isCreate: boolean) => (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
const r = useRisks()
return (
<AdminLayout title="Risk Matrix" description="Risikobewertung & Management">
@@ -519,17 +38,17 @@ export default function RisksPage() {
{/* View Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
onClick={() => r.setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
r.viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('list')}
onClick={() => r.setViewMode('list')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
r.viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Liste
@@ -537,7 +56,7 @@ export default function RisksPage() {
</div>
<button
onClick={openCreateModal}
onClick={r.openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Risiko hinzufuegen
@@ -545,44 +64,44 @@ export default function RisksPage() {
</div>
{/* Content */}
{loading ? (
{r.loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : risks.length === 0 ? (
) : r.risks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-slate-500 mb-4">Keine Risiken erfasst</p>
<button
onClick={openCreateModal}
onClick={r.openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstes Risiko hinzufuegen
</button>
</div>
) : viewMode === 'matrix' ? (
renderMatrix()
) : r.viewMode === 'matrix' ? (
<RiskMatrix risks={r.risks} onEditRisk={r.openEditModal} />
) : (
renderList()
<RiskList risks={r.risks} onEditRisk={r.openEditModal} />
)}
{/* Create Modal */}
{createModalOpen && (
{r.createModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neues Risiko</h3>
{renderForm(true)}
<RiskForm formData={r.formData} setFormData={r.setFormData} isCreate={true} />
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setCreateModalOpen(false)}
onClick={() => r.setCreateModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleCreate}
onClick={r.handleCreate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstellen
@@ -593,22 +112,22 @@ export default function RisksPage() {
)}
{/* Edit Modal */}
{editModalOpen && selectedRisk && (
{r.editModalOpen && r.selectedRisk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">
Risiko bearbeiten: {selectedRisk.risk_id}
Risiko bearbeiten: {r.selectedRisk.risk_id}
</h3>
{renderForm(false)}
<RiskForm formData={r.formData} setFormData={r.setFormData} isCreate={false} />
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setEditModalOpen(false)}
onClick={() => r.setEditModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleUpdate}
onClick={r.handleUpdate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Speichern
@@ -0,0 +1,83 @@
'use client'
import type { Document, Tab } from './types'
interface DocumentsTabProps {
documents: Document[]
loading: boolean
setSelectedDocument: (id: string) => void
setActiveTab: (tab: Tab) => void
}
export default function DocumentsTab({ documents, loading, setSelectedDocument, setActiveTab }: DocumentsTabProps) {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neues Dokument
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Dokumente vorhanden
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
{doc.type}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
<td className="py-3 px-4">
{doc.mandatory ? (
<span className="text-green-600">Ja</span>
) : (
<span className="text-slate-400">Nein</span>
)}
</td>
<td className="py-3 px-4 text-sm text-slate-500">
{new Date(doc.created_at).toLocaleDateString('de-DE')}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => {
setSelectedDocument(doc.id)
setActiveTab('versions')
}}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mr-3"
>
Versionen
</button>
<button className="text-slate-500 hover:text-slate-700 text-sm">
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,68 @@
'use client'
import { EMAIL_TEMPLATES, EMAIL_CATEGORIES, CATEGORY_ICONS } from './types'
export default function EmailsTab() {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen für automatisierte Kommunikation</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{EMAIL_CATEGORIES.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{EMAIL_CATEGORIES.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{EMAIL_TEMPLATES
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-primary-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
{CATEGORY_ICONS[category.key]}
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,109 @@
'use client'
import { GDPR_PROCESSES } from './types'
export default function GdprTab() {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
</div>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ DSR Anfrage erstellen
</button>
</div>
{/* Info Banner */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
<p className="text-sm text-purple-700 mt-1">
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
</p>
</div>
</div>
</div>
{/* GDPR Process Cards */}
<div className="space-y-4">
{GDPR_PROCESSES.map((process) => (
<div
key={process.article}
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
{process.article}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900">{process.title}</h3>
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
</div>
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
{/* Actions */}
<div className="flex flex-wrap gap-2 mb-3">
{process.actions.map((action, idx) => (
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
{action}
</span>
))}
</div>
{/* SLA */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
</span>
<span className="text-slate-300">|</span>
<span className="text-slate-500">
Offene Anfragen: <span className="font-medium text-slate-700">0</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<button className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg">
Anfragen
</button>
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Vorlage
</button>
</div>
</div>
</div>
))}
</div>
{/* DSR Request Statistics */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Übersicht</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-900">0</div>
<div className="text-xs text-slate-500 mt-1">Offen</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-700">0</div>
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-700">0</div>
<div className="text-xs text-slate-500 mt-1">Überfällig</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
'use client'
export default function StatsTab() {
return (
<div className="p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
</div>
<div className="bg-slate-50 rounded-xl p-6">
<div className="text-3xl font-bold text-slate-900">0</div>
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
</div>
</div>
<div className="border border-slate-200 rounded-lg p-6">
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
<div className="text-center py-8 text-slate-500">
Noch keine Daten verfügbar
</div>
</div>
</div>
)
}
@@ -0,0 +1,97 @@
'use client'
import type { Document, Version } from './types'
interface VersionsTabProps {
documents: Document[]
versions: Version[]
selectedDocument: string
setSelectedDocument: (id: string) => void
loading: boolean
}
export default function VersionsTab({ documents, versions, selectedDocument, setSelectedDocument, loading }: VersionsTabProps) {
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
<select
value={selectedDocument}
onChange={(e) => setSelectedDocument(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Dokument auswählen...</option>
{documents.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.name}
</option>
))}
</select>
</div>
{selectedDocument && (
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium">
+ Neue Version
</button>
)}
</div>
{!selectedDocument ? (
<div className="text-center py-12 text-slate-500">
Bitte wählen Sie ein Dokument aus
</div>
) : loading ? (
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
) : versions.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Versionen vorhanden
</div>
) : (
<div className="space-y-4">
{versions.map((version) => (
<div
key={version.id}
className="border border-slate-200 rounded-lg p-4 hover:border-primary-300 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900">v{version.version}</span>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
{version.language.toUpperCase()}
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
version.status === 'published'
? 'bg-green-100 text-green-700'
: version.status === 'draft'
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{version.status}
</span>
</div>
<h3 className="text-slate-700">{version.title}</h3>
<p className="text-sm text-slate-500 mt-1">
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
Bearbeiten
</button>
{version.status === 'draft' && (
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
Veröffentlichen
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,77 @@
export const API_BASE = '/api/admin/consent'
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
export interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
export interface Version {
id: string
document_id: string
version: string
language: string
title: string
content: string
status: string
created_at: string
}
export const TABS: { id: Tab; label: string }[] = [
{ id: 'documents', label: 'Dokumente' },
{ id: 'versions', label: 'Versionen' },
{ id: 'emails', label: 'E-Mail Vorlagen' },
{ id: 'gdpr', label: 'DSGVO Prozesse' },
{ id: 'stats', label: 'Statistiken' },
]
export const EMAIL_TEMPLATES = [
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
{ name: 'E-Mail Bestätigung', key: 'email_verification', category: 'onboarding', description: 'Bestätigungslink für E-Mail-Adresse' },
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestätigung der Kontoaktivierung' },
{ name: 'Passwort zurücksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zurücksetzen des Passworts' },
{ name: 'Passwort geändert', key: 'password_changed', category: 'security', description: 'Bestätigung der Passwortänderung' },
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung über Anmeldung von neuem Gerät' },
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestätigung der 2FA-Aktivierung' },
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info über neue Dokumentversion zur Zustimmung' },
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestätigung der erteilten Zustimmung' },
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestätigung des Widerrufs' },
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestätigung des Eingangs einer DSGVO-Anfrage' },
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung über fertigen Datenexport' },
{ name: 'Daten gelöscht', key: 'data_deleted', category: 'gdpr', description: 'Bestätigung der Datenlöschung' },
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestätigung der Datenberichtigung' },
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
{ name: 'Konto gelöscht', key: 'account_deleted', category: 'lifecycle', description: 'Bestätigung der Kontolöschung' },
]
export const GDPR_PROCESSES = [
{ article: '15', title: 'Auskunftsrecht', description: 'Recht auf Bestätigung und Auskunft über verarbeitete personenbezogene Daten', actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfänger auflisten'], sla: '30 Tage', status: 'active' },
{ article: '16', title: 'Recht auf Berichtigung', description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten', actions: ['Daten bearbeiten', 'Änderungshistorie führen', 'Benachrichtigung senden'], sla: '30 Tage', status: 'active' },
{ article: '17', title: 'Recht auf Löschung ("Vergessenwerden")', description: 'Recht auf Löschung personenbezogener Daten unter bestimmten Voraussetzungen', actions: ['Löschantrag prüfen', 'Daten löschen', 'Aufbewahrungsfristen prüfen', 'Löschbestätigung senden'], sla: '30 Tage', status: 'active' },
{ article: '18', title: 'Recht auf Einschränkung der Verarbeitung', description: 'Recht auf Markierung von Daten zur eingeschränkten Verarbeitung', actions: ['Daten markieren', 'Verarbeitung einschränken', 'Benachrichtigung bei Aufhebung'], sla: '30 Tage', status: 'active' },
{ article: '19', title: 'Mitteilungspflicht', description: 'Pflicht zur Mitteilung von Berichtigung, Löschung oder Einschränkung an Empfänger', actions: ['Empfänger ermitteln', 'Mitteilungen versenden', 'Protokollierung'], sla: 'Unverzüglich', status: 'active' },
{ article: '20', title: 'Recht auf Datenübertragbarkeit', description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format', actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Übertragung'], sla: '30 Tage', status: 'active' },
{ article: '21', title: 'Widerspruchsrecht', description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung', actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'], sla: 'Unverzüglich', status: 'active' },
]
export const EMAIL_CATEGORIES = [
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
]
export const CATEGORY_ICONS: Record<string, string> = {
onboarding: '👋',
security: '🔒',
consent: '✓',
gdpr: '📋',
lifecycle: '🔄',
}

Some files were not shown because too many files have changed in this diff Show More