streamlined neutralization flow

This commit is contained in:
ValueOn AG 2026-03-30 00:15:01 +02:00
parent 317e019b18
commit 9d4e5bc90d
3 changed files with 361 additions and 67 deletions

View file

@ -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 && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Platzhalter-Mappings</h2>
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
<div
style={{
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'var(--surface-color, #eff6ff)',
border: '1px solid var(--border-color, #bfdbfe)',
borderRadius: 8,
fontSize: '0.85rem',
lineHeight: 1.5,
color: 'var(--text-primary, #1e3a5f)',
}}
>
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
<strong>Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung</strong> (nicht auf dieser
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
</div>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
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.
</p>
{mappings.length === 0 ? (

View file

@ -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<string, unknown>): 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<string, NeutralizationMapping[]>;
unscoped: NeutralizationMapping[];
} {
const byFile: Record<string, NeutralizationMapping[]> = {};
const unscoped: NeutralizationMapping[] = [];
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const raw = item as Record<string, unknown>;
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<string, string> = {
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<string, NeutralizationMapping>,
): 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(<span key={`t-${lastIdx}`}>{text.slice(lastIdx, match.index)}</span>);
}
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(
<span
key={`ph-${match.index}`}
title={mapping ? `${mapping.originalText} (${phType})` : phType}
style={{
background: color + '18',
color,
border: `1px solid ${color}40`,
borderRadius: 4,
padding: '1px 4px',
fontFamily: 'monospace',
fontSize: '0.78rem',
cursor: 'help',
whiteSpace: 'nowrap',
}}
>
{fullPh}
</span>,
);
lastIdx = match.index + match[0].length;
}
if (lastIdx < text.length) {
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx)}</span>);
}
return parts;
}
const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId }) => {
const [sources, setSources] = useState<NeutralizationSource[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
const [attributeByFile, setAttributeByFile] = useState<Record<string, NeutralizationMapping[]>>({});
const [attributeUnscoped, setAttributeUnscoped] = useState<NeutralizationMapping[]>([]);
const [snapshots, setSnapshots] = useState<NeutralizationSnapshot[]>([]);
const [expandedSnapshot, setExpandedSnapshot] = useState<string | null>(null);
const _mappingLookup = useMemo(() => {
const map = new Map<string, NeutralizationMapping>();
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<string, unknown>) => 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<NeutralizationPanelProps> = ({ 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<NeutralizationPanelProps> = ({ 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<NeutralizationPanelProps> = ({ instanceId })
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>Lade Neutralisierungsdaten...</div>;
const _hasAnyData = sources.length > 0 || snapshots.length > 0;
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Neutralisierung</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original &harr; Platzhalter).
</p>
{sources.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Keine Datenquellen mit aktiver Neutralisierung.
</div>
) : (
{/* ── Snapshots: neutralisierter Text ──────────────────────── */}
{snapshots.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
Neutralisierter Text ({snapshots.length})
</div>
{snapshots.map((snap) => {
const _isExpanded = expandedSnapshot === snap.id;
return (
<div key={snap.id} style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div
onClick={() => 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',
}}
>
<span style={{ fontWeight: 500 }}>{snap.sourceLabel}</span>
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
{snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'}
</span>
</div>
{_isExpanded && (
<div
style={{
padding: '10px 12px',
fontSize: '0.82rem',
lineHeight: 1.6,
maxHeight: 400,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'var(--bg-primary, #fff)',
}}
>
{_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */}
{sources.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
Datenquellen
</div>
{sources.map((src) => (
<div
key={src.fileId}
style={{
padding: '12px 16px', borderRadius: 8,
border: selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px',
borderRadius: 8,
border:
selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onClick={() => setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>{src.fileName}</div>
<div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: 2 }}>
{_statusBadge(src.neutralizationStatus)}
{src.mappingCount > 0 && (
<span style={{ marginLeft: 8, color: '#9ca3af' }}>{src.mappingCount} Mapping(s)</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={(e) => { e.stopPropagation(); _handleRetrigger(src.fileId); }}
style={{ fontSize: '0.8rem', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color, #d1d5db)', background: 'transparent', cursor: 'pointer' }}
>
Erneut neutralisieren
</button>
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
</span>
{!src.isVirtual && (
<button
onClick={(e) => {
e.stopPropagation();
_handleRetrigger(src.fileId);
}}
style={{
fontSize: '0.8rem',
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--border-color, #d1d5db)',
background: 'transparent',
cursor: 'pointer',
}}
>
Erneut neutralisieren
</button>
)}
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}</span>
</div>
</div>
))}
</div>
)}
{/* ── Mappings für ausgewählte Quelle ──────────────────────── */}
{selectedSource && mappings.length > 0 && (
<div style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '8px 16px', background: 'var(--bg-hover, #f9fafb)', fontSize: '0.85rem', fontWeight: 500 }}>
@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{mappings.map((m) => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '8px 16px', borderTop: '1px solid var(--border-color, #f3f4f6)', fontSize: '0.8rem', gap: 12 }}>
<span style={{ flex: 1, fontFamily: 'monospace', color: '#4f46e5' }}>{m.placeholder}</span>
<div
key={m.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderTop: '1px solid var(--border-color, #f3f4f6)',
fontSize: '0.8rem',
gap: 12,
}}
>
<span style={{ flex: 1, fontFamily: 'monospace', color: _phTypeColors[m.patternType] || '#4f46e5' }}>{m.placeholder}</span>
<span style={{ color: '#9ca3af' }}>{'\u2192'}</span>
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.originalText}</span>
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.originalText}>
{m.originalText}
</span>
<span style={{ fontSize: '0.7rem', color: '#9ca3af', flexShrink: 0 }}>{m.patternType}</span>
<button
onClick={() => _handleDeleteMapping(m.id)}
@ -182,7 +446,15 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
{selectedSource && mappings.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Keine Mappings für diese Datenquelle.
{selectedSource === _chatPromptSourceId
? 'Keine Mappings ohne Dateizuordnung.'
: 'Keine gespeicherten Mappings für diese Datenquelle.'}
</div>
)}
{!_hasAnyData && (
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.
</div>
)}
</div>

View file

@ -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 = () => {
<WorkspaceGeneralSettings instanceId={instanceId} />
)}
{activeTab === 'neutralization' && (
<NeutralizationPanel instanceId={instanceId} />
<>
<p style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser
Workspace-Instanz. (Die Benutzer-Einstellungen unter <strong>/settings</strong> Neutralisierung (lokal)
ist eine andere Seite.)
</p>
<NeutralizationPanel instanceId={instanceId} />
</>
)}
</div>
</div>