[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:
@@ -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">🔍</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">⚙</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">↑ Ende vorheriger Chunk #{docChunkIndex - 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current chunk text */}
|
||||
{currentChunk ? (
|
||||
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
|
||||
{getChunkText(currentChunk)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
|
||||
)}
|
||||
|
||||
{/* Overlap from next chunk */}
|
||||
{nextChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↓ Anfang naechster Chunk #{docChunkIndex + 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{currentChunk && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100">
|
||||
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{Object.entries(currentChunk)
|
||||
.filter(([k]) => !HIDDEN_KEYS.has(k))
|
||||
.sort(([a], [b]) => {
|
||||
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">⚠ Sehr kurz</span>
|
||||
)}
|
||||
{getChunkText(currentChunk).length > 2000 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr lang</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF-Viewer Panel */}
|
||||
{splitViewActive && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite ~{pdfPage}
|
||||
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
|
||||
</span>
|
||||
{pdfUrl && (
|
||||
<a
|
||||
href={pdfUrl.split('#')[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:text-teal-800 underline"
|
||||
>
|
||||
Oeffnen ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{pdfUrl && pdfExists ? (
|
||||
<iframe
|
||||
key={`${selectedRegulation}-${pdfPage}`}
|
||||
src={pdfUrl}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
title="Original PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400 text-sm p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl">📄</div>
|
||||
{!pdfMapping ? (
|
||||
<>
|
||||
<p>Kein PDF-Mapping fuer {selectedRegulation}.</p>
|
||||
<p className="text-xs">rag-pdf-mapping.ts ergaenzen.</p>
|
||||
</>
|
||||
) : pdfExists === false ? (
|
||||
<>
|
||||
<p className="font-medium text-orange-600">PDF nicht vorhanden</p>
|
||||
<p className="text-xs">Datei <code className="bg-slate-100 px-1 rounded">{pdfMapping.filename}</code> fehlt in ~/rag-originals/</p>
|
||||
<p className="text-xs mt-1">Bitte manuell herunterladen und dort ablegen.</p>
|
||||
</>
|
||||
) : (
|
||||
<p>PDF wird geprueft...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
◀ Zurueck
|
||||
</button>
|
||||
<span className="text-sm font-mono text-slate-600 min-w-[80px] text-center">
|
||||
{docChunkIndex + 1} / {docTotalChunks}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={docChunkIndex >= docChunks.length - 1}
|
||||
className="px-3 py-1.5 text-sm font-medium border rounded-lg bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter ▶
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={docTotalChunks}
|
||||
value={docChunkIndex + 1}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v) && v >= 1 && v <= docTotalChunks) setDocChunkIndex(v - 1)
|
||||
}}
|
||||
className="w-16 px-2 py-1 border rounded text-xs text-center"
|
||||
title="Springe zu Chunk Nr."
|
||||
<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 ? '✕ Vollbild beenden' : '⛶ Vollbild'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content: Sidebar + Content — fills remaining height */}
|
||||
<div className="flex gap-3 flex-1 min-h-0">
|
||||
{/* Sidebar — scrollable */}
|
||||
<div className="w-56 flex-shrink-0 bg-white rounded-xl border border-slate-200 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 p-3 border-b border-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
placeholder="Suche..."
|
||||
className="w-full px-2 py-1.5 border rounded-lg text-sm focus:ring-2 focus:ring-teal-500"
|
||||
<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">🔍</div>
|
||||
<p className="text-sm">Dokument in der Sidebar auswaehlen, um QA zu starten.</p>
|
||||
<p className="text-xs text-slate-300">Pfeiltasten: Chunk vor/zurueck</p>
|
||||
</div>
|
||||
</div>
|
||||
) : docLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-white rounded-xl border border-slate-200">
|
||||
<div className="text-center text-slate-500 space-y-2">
|
||||
<div className="animate-spin text-3xl">⚙</div>
|
||||
<p className="text-sm">Chunks werden geladen...</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{selectedRegulation}: {REGULATIONS_IN_RAG[selectedRegulation]?.chunks.toLocaleString() || '?'} Chunks erwartet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex-1 grid gap-3 min-h-0 ${splitViewActive ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Chunk-Text Panel — fixed height, internal scroll */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Panel header */}
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Chunk-Text</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{structInfo.article && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs font-medium rounded border border-blue-200">
|
||||
{structInfo.article}
|
||||
</span>
|
||||
)}
|
||||
{structInfo.section && (
|
||||
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-xs rounded border border-purple-200">
|
||||
{structInfo.section}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
#{docChunkIndex} / {docTotalChunks - 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4 space-y-3">
|
||||
{/* Overlap from previous chunk */}
|
||||
{prevChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↑ Ende vorheriger Chunk #{docChunkIndex - 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapPrev()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current chunk text */}
|
||||
{currentChunk ? (
|
||||
<div className="text-sm text-slate-800 whitespace-pre-wrap break-words leading-relaxed border-l-2 border-teal-400 pl-3">
|
||||
{getChunkText(currentChunk)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400 italic">Kein Chunk-Text vorhanden.</div>
|
||||
)}
|
||||
|
||||
{/* Overlap from next chunk */}
|
||||
{nextChunk && (
|
||||
<div className="text-xs text-slate-400 bg-amber-50 border-l-2 border-amber-300 px-3 py-2 rounded-r">
|
||||
<div className="font-medium text-amber-600 mb-1">↓ Anfang naechster Chunk #{docChunkIndex + 1}</div>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{getOverlapNext()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{currentChunk && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-100">
|
||||
<div className="text-xs font-medium text-slate-500 mb-2">Metadaten</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{Object.entries(currentChunk)
|
||||
.filter(([k]) => !HIDDEN_KEYS.has(k))
|
||||
.sort(([a], [b]) => {
|
||||
// Structural keys first
|
||||
const aStruct = STRUCTURAL_KEYS.has(a) ? 0 : 1
|
||||
const bStruct = STRUCTURAL_KEYS.has(b) ? 0 : 1
|
||||
return aStruct - bStruct || a.localeCompare(b)
|
||||
})
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className={`flex gap-1 ${STRUCTURAL_KEYS.has(k) ? 'col-span-2 font-medium' : ''}`}>
|
||||
<span className="font-medium text-slate-500 flex-shrink-0">{k}:</span>
|
||||
<span className="text-slate-700 break-all">
|
||||
{Array.isArray(v) ? v.join(', ') : String(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Chunk quality indicator */}
|
||||
<div className="mt-3 pt-2 border-t border-slate-50">
|
||||
<div className="text-xs text-slate-400">
|
||||
Chunk-Laenge: {getChunkText(currentChunk).length} Zeichen
|
||||
{getChunkText(currentChunk).length < 50 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr kurz</span>
|
||||
)}
|
||||
{getChunkText(currentChunk).length > 2000 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">⚠ Sehr lang</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF-Viewer Panel */}
|
||||
{splitViewActive && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-slate-50 border-b border-slate-100 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Original-PDF</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite ~{pdfPage}
|
||||
{pdfMapping?.totalPages ? ` / ${pdfMapping.totalPages}` : ''}
|
||||
</span>
|
||||
{pdfUrl && (
|
||||
<a
|
||||
href={pdfUrl.split('#')[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:text-teal-800 underline"
|
||||
>
|
||||
Oeffnen ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{pdfUrl && pdfExists ? (
|
||||
<iframe
|
||||
key={`${selectedRegulation}-${pdfPage}`}
|
||||
src={pdfUrl}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
title="Original PDF"
|
||||
<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">📄</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} — {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"
|
||||
>
|
||||
◀ 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 ▶
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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§ion=${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§ion=${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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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 {}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 "Neuen Filter erstellen" und geben Sie bei "Von" 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 "Weiterleiten an" und fuegen Sie die obige Adresse ein. Aktivieren Sie auch "Filter auf passende Konversationen anwenden".
|
||||
</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 "RSS-Feed" 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>
|
||||
|
||||
|
||||
@@ -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 "Neuen Filter erstellen" und geben Sie bei "Von" 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 "Weiterleiten an" und fuegen Sie die obige Adresse ein. Aktivieren Sie auch "Filter auf passende Konversationen anwenden".
|
||||
</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 "RSS-Feed" 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user