diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 84691a2..bfe6de9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -23,7 +23,7 @@ const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'profile', label: 'Profil' }, { key: 'appearance', label: 'Darstellung' }, { key: 'voice', label: 'Stimme & Sprache' }, - { key: 'neutralization', label: 'Datenneutralisierung' }, + { key: 'neutralization', label: 'Neutralisierung (lokal)' }, { key: 'privacy', label: 'Datenschutz' }, ]; @@ -358,12 +358,27 @@ const NeutralizationMappingsTab: React.FC = () => { {error &&
{error}
}
-

Platzhalter-Mappings

+

Platzhalter-Mappings (lokal)

+
+ AI-Workspace: Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '} + Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“ (nicht auf dieser + Seite). Dieser Tab zeigt nur die lokale Liste über /api/local/neutralization-mappings. +

Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber - den AI-Service). Diese Liste betrifft nur Ihre gespeicherten Platzhalter-Zuordnungen — hier einsehbar und - loeschbar. + den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.

{mappings.length === 0 ? ( diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx index 22a5812..c8e1179 100644 --- a/src/pages/views/workspace/NeutralizationPanel.tsx +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -1,6 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import api from '../../../api'; +const _chatPromptSourceId = '__chat_prompt__'; +const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g; + interface NeutralizationMapping { id: string; originalText: string; @@ -11,38 +14,220 @@ interface NeutralizationMapping { createdAt?: string; } +interface NeutralizationSnapshot { + id: string; + sourceLabel: string; + neutralizedText: string; + placeholderCount: number; +} + interface NeutralizationSource { fileId: string; fileName: string; neutralizationStatus: string; mappingCount: number; + isVirtual?: boolean; } interface NeutralizationPanelProps { instanceId: string; } +function _normalizeApiRow(raw: Record): NeutralizationMapping { + const id = String(raw.id ?? ''); + const patternType = String(raw.patternType ?? 'unknown'); + const existingPh = raw.placeholder; + const placeholder = + typeof existingPh === 'string' && existingPh + ? existingPh + : id + ? `[${patternType}.${id}]` + : ''; + return { + id, + originalText: String(raw.originalText ?? ''), + placeholder, + patternType, + fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined, + fileName: raw.fileName != null ? String(raw.fileName) : undefined, + createdAt: + raw.createdAt != null + ? String(raw.createdAt) + : raw.sysCreatedAt != null + ? String(raw.sysCreatedAt) + : undefined, + }; +} + +function _partitionAttributes(rows: unknown[]): { + byFile: Record; + unscoped: NeutralizationMapping[]; +} { + const byFile: Record = {}; + const unscoped: NeutralizationMapping[] = []; + for (const item of rows) { + if (!item || typeof item !== 'object') continue; + const raw = item as Record; + const m = _normalizeApiRow(raw); + const fid = raw.fileId; + if (fid == null || fid === '') { + unscoped.push(m); + } else { + const key = String(fid); + if (!byFile[key]) byFile[key] = []; + byFile[key].push(m); + } + } + return { byFile, unscoped }; +} + +const _phTypeColors: Record = { + name: '#7c3aed', + email: '#2563eb', + phone: '#0891b2', + address: '#059669', + financial: '#d97706', + id: '#dc2626', + logic: '#be185d', + company: '#4f46e5', + product: '#7c3aed', + location: '#059669', + other: '#6b7280', +}; + +function _renderHighlightedText( + text: string, + mappingLookup: Map, +): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIdx = 0; + const rx = new RegExp(_placeholderRx.source, 'g'); + let match: RegExpExecArray | null; + + while ((match = rx.exec(text)) !== null) { + if (match.index > lastIdx) { + parts.push({text.slice(lastIdx, match.index)}); + } + const phType = match[1]; + const phId = match[2]; + const fullPh = match[0]; + const mapping = mappingLookup.get(phId); + const color = _phTypeColors[phType] || _phTypeColors.other; + parts.push( + + {fullPh} + , + ); + lastIdx = match.index + match[0].length; + } + if (lastIdx < text.length) { + parts.push({text.slice(lastIdx)}); + } + return parts; +} + const NeutralizationPanel: React.FC = ({ instanceId }) => { const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [mappings, setMappings] = useState([]); const [loading, setLoading] = useState(true); + const [attributeByFile, setAttributeByFile] = useState>({}); + const [attributeUnscoped, setAttributeUnscoped] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [expandedSnapshot, setExpandedSnapshot] = useState(null); + + const _mappingLookup = useMemo(() => { + const map = new Map(); + for (const m of attributeUnscoped) map.set(m.id, m); + for (const arr of Object.values(attributeByFile)) { + for (const m of arr) map.set(m.id, m); + } + return map; + }, [attributeUnscoped, attributeByFile]); const _loadSources = useCallback(async () => { setLoading(true); try { - const response = await api.get(`/api/workspace/${instanceId}/files`); - const raw = response.data; - const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []); - const neutralized = (Array.isArray(files) ? files : []) - .filter((f: any) => f.neutralize) - .map((f: any) => ({ - fileId: f.id, - fileName: f.fileName || f.name || 'unknown', - neutralizationStatus: f.neutralizationStatus || f.status || 'unknown', - mappingCount: 0, - })); - setSources(neutralized); + const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined; + const [filesResponse, attrResponse] = await Promise.all([ + api.get(`/api/workspace/${instanceId}/files`, { headers }), + api.get('/api/neutralization/attributes', { headers }), + ]); + + let snapAxios: { data: unknown } = { data: [] }; + try { + const _enc = encodeURIComponent(instanceId); + snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers }); + } catch (_snapErr) { + console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr); + try { + snapAxios = await api.get('/api/neutralization/snapshots', { headers }); + } catch (_snapErr2) { + console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2); + snapAxios = { data: [] }; + } + } + + const rawFiles = filesResponse.data; + const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []); + const fileList = Array.isArray(files) ? files : []; + + const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? []; + const attrRows = Array.isArray(attrPayload) ? attrPayload : []; + const { byFile, unscoped } = _partitionAttributes(attrRows); + setAttributeByFile(byFile); + setAttributeUnscoped(unscoped); + + const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined; + const snapPayload = + Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody + ? ( _snapBody as { data: unknown }).data + : _snapBody) ?? []; + const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : []; + setSnapshots(snapList); + if (snapList.length > 0 && snapList[0].id) { + setExpandedSnapshot(snapList[0].id); + } else { + setExpandedSnapshot(null); + } + + const neutralizedFiles = fileList.filter((f: Record) => f.neutralize); + + const nextSources: NeutralizationSource[] = []; + if (unscoped.length > 0) { + nextSources.push({ + fileId: _chatPromptSourceId, + fileName: 'Chat, Prompt & Kontext', + neutralizationStatus: 'completed', + mappingCount: unscoped.length, + isVirtual: true, + }); + } + for (const f of neutralizedFiles) { + const fid = String(f.id ?? ''); + if (!fid) continue; + nextSources.push({ + fileId: fid, + fileName: String(f.fileName ?? f.name ?? 'unknown'), + neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'), + mappingCount: byFile[fid]?.length ?? 0, + }); + } + setSources(nextSources); } catch (err) { console.error('Failed to load neutralization sources:', err); } finally { @@ -50,35 +235,28 @@ const NeutralizationPanel: React.FC = ({ instanceId }) } }, [instanceId]); - const _loadMappings = useCallback(async (fileId: string) => { - try { - const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } }); - const data = response.data?.data || response.data || []; - setMappings(data.map((m: any) => ({ - id: m.id, - originalText: m.originalText || '', - placeholder: m.placeholder || m.id, - patternType: m.patternType || 'unknown', - fileId: m.fileId, - fileName: m.fileName, - createdAt: m.createdAt || m.sysCreatedAt, - }))); - } catch (err) { - console.error('Failed to load mappings:', err); - setMappings([]); - } - }, [instanceId]); - - useEffect(() => { _loadSources(); }, [_loadSources]); + useEffect(() => { + _loadSources(); + }, [_loadSources]); useEffect(() => { - if (selectedSource) _loadMappings(selectedSource); - }, [selectedSource, _loadMappings]); + if (!selectedSource) { + setMappings([]); + return; + } + if (selectedSource === _chatPromptSourceId) { + setMappings(attributeUnscoped); + return; + } + setMappings(attributeByFile[selectedSource] ?? []); + }, [selectedSource, attributeByFile, attributeUnscoped]); const _handleDeleteMapping = async (mappingId: string) => { try { - await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`); - setMappings(prev => prev.filter(m => m.id !== mappingId)); + await api.delete(`/api/neutralization/attributes/single/${mappingId}`, { + headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined, + }); + await _loadSources(); } catch (err) { console.error('Failed to delete mapping:', err); } @@ -86,8 +264,12 @@ const NeutralizationPanel: React.FC = ({ instanceId }) const _handleRetrigger = async (fileId: string) => { try { - await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId }); - _loadSources(); + await api.post( + '/api/neutralization/retrigger', + { fileId }, + { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined }, + ); + await _loadSources(); } catch (err) { console.error('Failed to retrigger neutralization:', err); } @@ -110,26 +292,82 @@ const NeutralizationPanel: React.FC = ({ instanceId }) if (loading) return
Lade Neutralisierungsdaten...
; + const _hasAnyData = sources.length > 0 || snapshots.length > 0; + return (

Neutralisierung

- Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings. + Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).

- {sources.length === 0 ? ( -
- Keine Datenquellen mit aktiver Neutralisierung. -
- ) : ( + {/* ── Snapshots: neutralisierter Text ──────────────────────── */} + {snapshots.length > 0 && (
+
+ Neutralisierter Text ({snapshots.length}) +
+ {snapshots.map((snap) => { + const _isExpanded = expandedSnapshot === snap.id; + return ( +
+
setExpandedSnapshot(_isExpanded ? null : snap.id)} + style={{ + padding: '8px 12px', + background: 'var(--bg-hover, #f9fafb)', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontSize: '0.85rem', + }} + > + {snap.sourceLabel} + + {snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'} + +
+ {_isExpanded && ( +
+ {_renderHighlightedText(snap.neutralizedText, _mappingLookup)} +
+ )} +
+ ); + })} +
+ )} + + {/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */} + {sources.length > 0 && ( +
+
+ Datenquellen +
{sources.map((src) => (
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} > @@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{src.fileName}
{_statusBadge(src.neutralizationStatus)} + {src.mappingCount > 0 && ( + {src.mappingCount} Mapping(s) + )}
- - - {selectedSource === src.fileId ? '\u25BC' : '\u25B6'} - + {!src.isVirtual && ( + + )} + {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
))}
)} + {/* ── Mappings für ausgewählte Quelle ──────────────────────── */} {selectedSource && mappings.length > 0 && (
@@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{mappings.map((m) => ( -
- {m.placeholder} +
+ {m.placeholder} {'\u2192'} - {m.originalText} + + {m.originalText} + {m.patternType}
diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 8f25088..644f253 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -14,7 +14,7 @@ type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'neutralization', label: 'Neutralisierung' }, + { key: 'neutralization', label: 'Neutralisierung (Workspace)' }, ]; export const WorkspaceSettingsPage: React.FC = () => { @@ -67,7 +67,14 @@ export const WorkspaceSettingsPage: React.FC = () => { )} {activeTab === 'neutralization' && ( - + <> +

+ Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser + Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“ + ist eine andere Seite.) +

+ + )}