rag stats

This commit is contained in:
ValueOn AG 2026-03-23 00:18:06 +01:00
parent 0367563ea8
commit 064635ae74
5 changed files with 360 additions and 1 deletions

View file

@ -156,6 +156,7 @@ function App() {
{/* Workspace Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />

View file

@ -34,6 +34,7 @@ import { AutomationDefinitionsView, AutomationTemplatesView } from './views/auto
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
// Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -130,6 +131,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
'rag-insights': WorkspaceRagInsightsPage,
settings: WorkspaceSettingsPage,
},
teamsbot: {
@ -196,7 +198,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
return null;
}

View file

@ -0,0 +1,82 @@
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1200px;
}
.disclaimer {
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-secondary, #666);
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
border: 1px solid var(--border-color, #e8e8e8);
}
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.kpiCard {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
margin: 0 0 0.25rem;
}
.kpiLabel {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.3;
}
.chartBlock {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
min-height: 280px;
}
.chartTitle {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--text-primary, #1a1a1a);
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row2 {
grid-template-columns: 1fr;
}
}
.meta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.5rem;
}
.error {
color: #c62828;
padding: 1rem;
}

View file

@ -0,0 +1,273 @@
/**
* WorkspaceRagInsightsPage Aggregierte, nicht personenbezogene Kennzahlen zum
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
const MIME_LABELS: Record<string, string> = {
pdf: 'PDF',
office_doc: 'Office (Text)',
office_sheet: 'Office (Tabellen)',
office_slides: 'Office (Folien)',
text: 'Text',
image: 'Bild',
html: 'HTML',
other: 'Sonstige',
};
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
function _formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RagStatsResponse {
error?: string;
scope?: {
featureInstanceId?: string;
mandateScopedShared?: boolean;
workspaceFileIdsResolved?: number;
};
kpis?: RagKpis;
indexedDocumentsByStatus?: Record<string, number>;
documentsByMimeCategory?: Record<string, number>;
chunksByContentType?: Record<string, number>;
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
generatedAtUtc?: string;
}
export const WorkspaceRagInsightsPage: React.FC = () => {
const instanceId = useInstanceId();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<RagStatsResponse | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = (await request({
url: `/api/workspace/${instanceId}/rag-statistics`,
method: 'get',
})) as RagStatsResponse;
if (data?.error) {
setError(String(data.error));
setStats(null);
} else {
setStats(data ?? null);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen');
setStats(null);
} finally {
setLoading(false);
}
}, [instanceId, request]);
useEffect(() => {
void load();
}, [load]);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
Keine Workspace-Instanz ausgewählt.
</div>
);
}
if (loading) {
return <div className={styles.wrap} style={{ padding: 24 }}>Lade Kennzahlen </div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
const kpis = stats?.kpis;
const timeline = stats?.timelineIndexedDocuments ?? [];
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
name: MIME_LABELS[key] ?? key,
value,
}));
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
name,
value,
}));
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
name,
value,
}));
return (
<div className={styles.wrap}>
<p className={styles.disclaimer}>
Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl
Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen
oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.
</p>
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
<p className={styles.meta} style={{ marginTop: 0 }}>
Zuordnung Knowledge Dateien: {stats.scope.workspaceFileIdsResolved} Datei-ID(s) mit
dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die
Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter
Indexierung.
</p>
)}
{kpis && (
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{_formatBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
<p className={styles.kpiLabel}>Inhalts-Fragmente (Chunks)</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{kpis.embeddingCoveragePercent}%
</p>
<p className={styles.kpiLabel}>Anteil Fragmente mit Embedding</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
<p className={styles.kpiLabel}>Beitragende Benutzer (Anzahl)</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
<p className={styles.kpiLabel}>Workflow-Entitäten (Cache)</p>
</div>
</div>
)}
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>Neu indexierte Dokumente pro Tag (letzte Wochen)</h3>
{timeline.length === 0 ? (
<p className={styles.meta}>Keine Zeitreihen-Daten für den gewählten Zeitraum.</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="indexedDocuments" name="Dokumente" stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.row2}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>Dokumente nach Format-Kategorie</h3>
{mimeRows.length === 0 ? (
<p className={styles.meta}>Keine Daten.</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name="Anzahl" fill="#00897b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>Index-Status</h3>
{statusRows.length === 0 ? (
<p className={styles.meta}>Keine Daten.</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={statusRows}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={88}
label={({ name, percent }) =>
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
>
{statusRows.map((_, i) => (
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>Fragmente nach Inhaltstyp</h3>
{chunkTypeRows.length === 0 ? (
<p className={styles.meta}>Keine Chunk-Daten.</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chunkTypeRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name="Fragmente" fill="#6a1b9a" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{stats?.generatedAtUtc && (
<p className={styles.meta}>Stand (UTC): {stats.generatedAtUtc}</p>
)}
</div>
);
};

View file

@ -290,6 +290,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
{ code: 'rag-insights', label: { de: 'Wissens-Insights', en: 'Knowledge insights', fr: 'Aperçu des connaissances' }, path: 'rag-insights' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
]
},