first commit

This commit is contained in:
team 1
2026-04-20 16:36:28 +02:00
parent a0ec07a99c
commit 2587ac8b4b
41 changed files with 5126 additions and 2280 deletions

View File

@@ -5,107 +5,108 @@
{% block body %}
<div class="container-fluid">
<!-- ===================================================== -->
<!-- HEADER -->
<!-- ===================================================== -->
{% set chunkStatus = vectorHealth.status|default('UNKNOWN') %}
{% set chunkBadgeClass =
chunkStatus starts with 'OK'
? 'bg-success'
: (chunkStatus == 'INCONSISTENT_MISSING_VECTOR'
? 'bg-warning text-dark'
: 'bg-danger')
%}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0"><i class="bi bi-hdd-rack"></i> Systemübersicht</h1>
<span class="badge bg-secondary">RAG Enterprise</span>
{% set tagStatus = tagVectorHealth.status|default('UNKNOWN') %}
{% set tagBadgeClass =
tagStatus starts with 'OK'
? 'bg-success'
: (tagStatus == 'INCONSISTENT_MISSING_VECTOR'
? 'bg-warning text-dark'
: 'bg-danger')
%}
{% set percent = chunkLimit > 0 ? (chunkCount / chunkLimit * 100)|round(3) : 0 %}
{% set percentClass =
percent >= 95
? 'bg-danger'
: (percent >= 85 ? 'bg-warning text-dark' : 'bg-success')
%}
{% set chunkHealthy = chunkStatus in ['OK', 'OK_EMPTY'] %}
{% set tagHealthy = tagStatus in ['OK', 'OK_EMPTY'] %}
{% set anyHealthIssue = not chunkHealthy or not tagHealthy %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h1 class="h3 mb-0">
<i class="bi bi-hdd-rack"></i> Systemübersicht
</h1>
<span class="badge bg-secondary">RetrieX Admin</span>
</div>
<!-- ===================================================== -->
<!-- KPI ROW (NUR STATUS-AMPELN) -->
<!-- ===================================================== -->
{% if anyHealthIssue %}
<div class="alert alert-warning shadow-sm mb-4">
<strong>Achtung:</strong>
Mindestens ein Index-Zustand ist nicht konsistent.
Prüfe die Detailkarten unten und führe bei Bedarf einen Global Reindex aus.
</div>
{% endif %}
<div class="row g-4 mb-4">
{# ================= CHUNK VECTOR STATUS ================= #}
{% if vectorHealth is defined %}
{% set status = vectorHealth.status %}
{% set badgeClass =
status starts with 'OK'
? 'bg-success'
: (status == 'INCONSISTENT_MISSING_VECTOR'
? 'bg-warning text-dark'
: 'bg-danger') %}
{% endif %}
<div class="col-lg-6 col-xl-3">
<div class="card bg-black border-secondary text-light h-100">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<div class="small text-light mb-2"><i class="bi bi-files"></i> Chunk-Vektor</div>
<div class="small text-light mb-2">
<i class="bi bi-files"></i> Chunk-Vektor
</div>
{% if vectorHealth is defined %}
<h4 class="mb-0">
<span class="badge {{ badgeClass }}">
{{ vectorHealth.status }}
<h4 class="mb-2">
<span class="badge {{ chunkBadgeClass }}">
{{ chunkStatus }}
</span>
</h4>
{% else %}
<div class="small text-light">
Keine Daten verfügbar.
</div>
{% endif %}
</h4>
<div class="small text-muted">
Keyword-/Chunk-Retrieval-Grundlage des Systems
</div>
</div>
</div>
</div>
{# ================= TAG VECTOR STATUS ================= #}
{% if tagVectorHealth is defined %}
{% set tagStatus = tagVectorHealth.status %}
{% set tagBadgeClass =
tagStatus starts with 'OK'
? 'bg-success'
: (tagStatus == 'INCONSISTENT_MISSING_VECTOR'
? 'bg-warning text-dark'
: 'bg-danger') %}
{% endif %}
<div class="col-lg-6 col-xl-3">
<div class="card bg-black border-secondary text-light h-100">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<div class="small text-light mb-2"><i class="bi bi-tags"></i> Tag-Vektor</div>
<div class="small text-light mb-2">
<i class="bi bi-tags"></i> Tag-Vektor
</div>
{% if tagVectorHealth is defined %}
<h4 class="mb-0">
<h4 class="mb-2">
<span class="badge {{ tagBadgeClass }}">
{{ tagVectorHealth.status }}
{{ tagStatus }}
</span>
</h4>
{% else %}
<div class="small text-light">
Keine Daten verfügbar.
</div>
{% endif %}
</h4>
<div class="small text-muted">
Semantisches Tag-Routing für Dokumenträume und Entity-Erkennung
</div>
</div>
</div>
</div>
{# ================= KNOWLEDGE CAPACITY ================= #}
{% set percent = chunkLimit > 0 ? (chunkCount / chunkLimit * 100)|round(3) : 0 %}
<div class="col-lg-6 col-xl-3">
<div class="card bg-black border-secondary text-light h-100">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<div class="small text-light mb-2"><i class="bi bi-robot"></i> Wissenskapazität</div>
<div class="small text-light mb-2">
<i class="bi bi-robot"></i> Wissenskapazität
</div>
<h4 class="mb-2">
{{ chunkCount|number_format(0, ',', '.') }}
<span class="text-secondary small">
/ {{ chunkLimit|number_format(0, ',', '.') }} Chunks
</span>
/ {{ chunkLimit|number_format(0, ',', '.') }} Chunks
</span>
</h4>
<div class="progress bg-dark mb-2" style="height: 14px;">
<div class="progress-bar
{% if percent >= 95 %}
bg-danger
{% elseif percent >= 85 %}
bg-warning text-dark
{% else %}
bg-success
{% endif %}"
<div class="progress-bar {{ percentClass }}"
style="width: {{ percent }}%;">
</div>
</div>
@@ -117,20 +118,21 @@
</div>
</div>
{# ================= GOVERNANCE ================= #}
<div class="col-lg-6 col-xl-3">
<div class="card bg-black border-secondary text-light h-100">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<div class="small text-light mb-2"><i class="bi bi-shield-check"></i> System-Governance</div>
<div class="small text-light mb-2">
<i class="bi bi-shield-check"></i> System-Governance
</div>
<div class="small">
<strong>Benutzer</strong><br>
{{ app.user.userIdentifier }}
{{ app.user ? app.user.userIdentifier : '-' }}
</div>
<div class="small mt-3">
<strong>Rollen</strong><br>
{{ app.user.roles|join(', ') }}
{{ app.user ? app.user.roles|join(', ') : '-' }}
</div>
</div>
</div>
@@ -138,65 +140,94 @@
</div>
<!-- ===================================================== -->
<!-- DETAIL ROW (HIER SIND DIE ZAHLEN) -->
<!-- ===================================================== -->
<div class="row g-4">
{% if vectorHealth is defined %}
<div class="col-lg-4">
<div class="card bg-black border-secondary text-light h-100">
<div class="card-body">
<h5 class="text-info mb-3"><i class="bi bi-files"></i> Chunk-Vektor-Details</h5>
<div class="small text-info">NDJSON-Chunks</div>
<div class="h5 mb-3">
{{ vectorHealth.ndjson_chunk_count|number_format(0, ',', '.') }}
</div>
<div class="small text-info">Vektor-Index-Chunks</div>
<div class="h5">
{{ vectorHealth.vector_chunk_count|number_format(0, ',', '.') }}
</div>
</div>
</div>
</div>
{% endif %}
{% if tagVectorHealth is defined %}
<div class="col-lg-4">
<div class="card bg-black border-secondary text-light h-100">
<div class="card-body">
<h5 class="text-info mb-3"><i class="bi bi-tags"></i> Tag-Vektor-Details</h5>
<div class="small text-info">NDJSON-Tags</div>
<div class="h5 mb-3">
{{ tagVectorHealth.tags_ndjson_count|number_format(0, ',', '.') }}
</div>
<div class="small text-info">Vektor-Index-Tags</div>
<div class="h5">
{{ tagVectorHealth.vector_tag_count|number_format(0, ',', '.') }}
</div>
</div>
</div>
</div>
{% endif %}
<!-- INDEXIERUNG -->
<div class="col-lg-4">
<div class="card bg-black border-secondary text-light h-100">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3"><i class="bi bi-search"></i> Indexierung (Ingest Jobs)</h5>
<h5 class="text-info mb-3">
<i class="bi bi-files"></i> Chunk-Vektor-Details
</h5>
<div class="small text-info">NDJSON-Chunks</div>
<div class="h5 mb-3">
{{ vectorHealth.ndjson_chunk_count|default(0)|number_format(0, ',', '.') }}
</div>
<div class="small text-info">Vektor-Index-Chunks</div>
<div class="h5 mb-3">
{{ vectorHealth.vector_chunk_count|default(0)|number_format(0, ',', '.') }}
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<span class="badge {{ vectorHealth.ndjson_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
NDJSON {{ vectorHealth.ndjson_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
<span class="badge {{ vectorHealth.vector_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
Index {{ vectorHealth.vector_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
<span class="badge {{ vectorHealth.meta_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
Meta {{ vectorHealth.meta_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">
<i class="bi bi-tags"></i> Tag-Vektor-Details
</h5>
<div class="small text-info">Exportierte Tags (NDJSON)</div>
<div class="h5 mb-3">
{{ tagVectorHealth.tags_ndjson_count|default(0)|number_format(0, ',', '.') }}
</div>
<div class="small text-info">Vektor-Index-Tags</div>
<div class="h5 mb-3">
{{ tagVectorHealth.vector_tag_count|default(0)|number_format(0, ',', '.') }}
</div>
<div class="small text-info">Tags mit aktiven Dokumenten</div>
<div class="h5 mb-3">
{{ tagVectorHealth.tags_with_active_document_ids|default(0)|number_format(0, ',', '.') }}
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<span class="badge {{ tagVectorHealth.tags_ndjson_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
NDJSON {{ tagVectorHealth.tags_ndjson_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
<span class="badge {{ tagVectorHealth.vector_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
Index {{ tagVectorHealth.vector_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
<span class="badge {{ tagVectorHealth.meta_exists|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
Meta {{ tagVectorHealth.meta_exists|default(false) ? 'vorhanden' : 'fehlt' }}
</span>
<span class="badge {{ tagVectorHealth.meta_valid|default(false) ? 'text-bg-success' : 'text-bg-danger' }}">
Meta {{ tagVectorHealth.meta_valid|default(false) ? 'gültig' : 'ungültig' }}
</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card bg-black border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">
<i class="bi bi-search"></i> Indexierung (Ingest Jobs)
</h5>
<div class="text-muted small mb-3">
Erstellt den kompletten Wissensindex neu.
Kann je nach Datenmenge mehrere Minuten dauern.
Erstellt den kompletten Wissensindex neu und zieht dabei auch die
physischen Retrieval-Artefakte wieder gerade.
</div>
<form method="post"
action="/admin/jobs/global-reindex"
action="{{ path('admin_global_reindex') }}"
onsubmit="return confirm('Global Reindex starten? Dies kann mehrere Minuten dauern.');">
<input type="hidden"
@@ -208,15 +239,23 @@
Global Reindex starten
</button>
</form>
{% if anyHealthIssue %}
<div class="alert alert-dark border border-warning text-light small mt-3 mb-0">
Empfohlen bei inkonsistentem Chunk- oder Tag-Zustand.
</div>
{% endif %}
</div>
</div>
</div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<div class="col-lg-4">
<div class="card bg-black border-danger text-light h-100">
<div class="card bg-black border-danger text-light h-100 shadow-sm">
<div class="card-body">
<h5 class="text-danger mb-3"><i class="bi bi-sign-stop-fill"></i> Kritische Systemoperationen</h5>
<h5 class="text-danger mb-3">
<i class="bi bi-sign-stop-fill"></i> Kritische Systemoperationen
</h5>
<div class="small mb-3 text-secondary">
Entfernt alle Dokumente, Versionen, Indizes und Jobs.

View File

@@ -4,8 +4,15 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0"><i class="bi bi-card-list"></i> Dokumente</h1>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-card-list"></i> Dokumente
</h1>
<div class="small text-muted">
Übersicht über Dokumente, aktive Versionen, Ingest-Zustände und Tag-Zuordnungen.
</div>
</div>
<a href="{{ path('admin_document_new') }}"
class="btn btn-sm btn-outline-info">
@@ -13,154 +20,222 @@
</a>
</div>
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('info') %}
<div class="alert alert-info shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Worauf achten?</h5>
<ul class="small mb-0">
<li><strong>INDEXED</strong> bedeutet: aktive Version ist sauber im Wissensindex.</li>
<li><strong>PENDING</strong> oder <strong>FAILED</strong> bedeuten: Dokument prüfen und ggf. Ingest erneut anstoßen.</li>
<li><strong>Tags</strong> sollten fachlich präzise sein und nicht nur generische Oberbegriffe abbilden.</li>
<li>Die aktive Version ist die fachlich führende Version des Dokuments.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Schnellzugriff</h5>
<div class="small text-light">
Über <strong>Tags</strong> gelangst du direkt in die Tag-Pflege des Dokuments.
Über <strong>Details</strong> steuerst du Versionen, Aktivierung, Re-Ingest und Löschung.
</div>
</div>
</div>
</div>
{% if documents is empty %}
<div class="alert alert-secondary">
<div class="alert alert-secondary shadow-sm">
Keine Dokumente vorhanden.
</div>
{% else %}
<div class="card bg-black border-secondary">
<div class="card bg-black border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th>Titel</th>
<th>ID</th>
<th>Typ</th>
<th>Status</th>
<th>Indexierung</th>
<th>Versionen</th>
<th>Aktive Version</th>
<th>Erstellt</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
<div class="d-flex justify-content-between align-items-center px-3 py-3 border-bottom border-secondary flex-wrap gap-2">
<div>
<strong class="text-info">Vorhandene Dokumente</strong>
<span class="small text-muted ms-2">{{ documents|length }} Einträge</span>
</div>
{% for document in documents %}
<div class="small text-muted">
Neueste Dokumente stehen oben.
</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th style="width: 20%">Titel</th>
<th style="width: 14%">ID</th>
<th style="width: 8%">Typ</th>
<th style="width: 8%">Status</th>
<th style="width: 10%">Indexierung</th>
<th style="width: 7%">Versionen</th>
<th style="width: 8%">Aktive Version</th>
<th style="width: 7%">Tags</th>
<th style="width: 8%">Erstellt</th>
<th class="text-end" style="width: 10%">Aktionen</th>
</tr>
</thead>
<tbody>
{# Titel #}
<td>
<a href="{{ path('admin_document_show', {id: document.id}) }}"
class="text-light text-decoration-none">
{{ document.title }}
</a>
</td>
{% for document in documents %}
<tr>
<td>
<div class="fw-semibold">
<a href="{{ path('admin_document_show', {id: document.id}) }}"
class="text-light text-decoration-none">
{{ document.title }}
</a>
</div>
{# ID #}
<td class="small text-info">
{{ document.id }}
</td>
{% if document.currentVersion and document.currentVersion.filePath %}
<div class="small text-muted mt-1">
Aktive Datei vorhanden
</div>
{% endif %}
</td>
{# Typ #}
<td>
{% if document.currentVersion %}
<span class="badge bg-secondary">
{{ document.currentVersion.fileTypeLabel }}
</span>
{% else %}
<span class="badge bg-dark border border-secondary">
-
</span>
{% endif %}
</td>
<td class="small text-info">
<code>{{ document.id }}</code>
</td>
{# Dokument Status #}
<td>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</td>
{# Ingest Status #}
<td>
{% if document.currentVersion %}
{% if document.currentVersion.ingestStatus == 'INDEXED' %}
<span class="badge bg-success">INDEXED</span>
{% elseif document.currentVersion.ingestStatus == 'PENDING' %}
<span class="badge bg-warning text-dark">PENDING</span>
{% elseif document.currentVersion.ingestStatus == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
<td>
{% if document.currentVersion %}
<span class="badge bg-secondary">
{{ document.currentVersion.fileTypeLabel }}
</span>
{% else %}
<span class="badge bg-dark border border-secondary">
{{ document.currentVersion.ingestStatus }}
</span>
-
</span>
{% endif %}
{% else %}
<span class="badge bg-dark border border-secondary">-</span>
{% endif %}
</td>
</td>
{# Version Count #}
<td>
{{ document.versions|length }}
</td>
<td>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</td>
{# Aktive Version #}
<td>
{% if document.currentVersion %}
v{{ document.currentVersion.versionNumber }}
{% else %}
-
{% endif %}
</td>
<td>
{% if document.currentVersion %}
{% if document.currentVersion.ingestStatus == 'INDEXED' %}
<span class="badge bg-success">INDEXED</span>
{% elseif document.currentVersion.ingestStatus == 'PENDING' %}
<span class="badge bg-warning text-dark">PENDING</span>
{% elseif document.currentVersion.ingestStatus == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif document.currentVersion.ingestStatus == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% else %}
<span class="badge bg-dark border border-secondary">
{{ document.currentVersion.ingestStatus ?: '-' }}
</span>
{% endif %}
{% else %}
<span class="badge bg-dark border border-secondary">-</span>
{% endif %}
</td>
{# Created At #}
<td class="small">
{{ document.createdAt|date('d.m.Y H:i') }}
</td>
<td>
<span class="badge text-bg-dark border border-secondary">
{{ document.versions|length }}
</span>
</td>
{# Aktionen #}
<td class="text-end">
<td>
{% if document.currentVersion %}
<span class="badge bg-info text-dark">
v{{ document.currentVersion.versionNumber }}
</span>
{% else %}
-
{% endif %}
</td>
<a class="btn btn-sm btn-outline-info me-2"
href="{{ path('admin_document_tags_edit', {id: document.id}) }}">
Tags
</a>
<td>
<span class="badge text-bg-dark border border-secondary">
{{ document.tags|length }}
</span>
</td>
<a class="btn btn-sm btn-outline-light me-2"
href="{{ path('admin_document_show', {id: document.id}) }}">
Details
</a>
<td class="small">
{{ document.createdAt|date('d.m.Y H:i') }}
</td>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_delete', {id: document.id}) }}"
class="d-inline"
onsubmit="return confirm('Dokument wirklich endgültig löschen? Diese Aktion entfernt Dokument, Versionen und Index-Daten.');">
<td class="text-end">
<div class="d-flex justify-content-end flex-wrap gap-2">
<a class="btn btn-sm btn-outline-info"
href="{{ path('admin_document_tags_edit', {id: document.id}) }}">
Tags
</a>
<input type="hidden"
name="_token"
value="{{ csrf_token('delete_document_' ~ document.id) }}">
<a class="btn btn-sm btn-outline-light"
href="{{ path('admin_document_show', {id: document.id}) }}">
Details
</a>
<button class="btn btn-sm btn-outline-danger">
Löschen
</button>
</form>
{% endif %}
{% if is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_delete', {id: document.id}) }}"
class="d-inline"
onsubmit="return confirm('Dokument wirklich löschen? Der Inhalt wird per Delete-Job aus dem Index entfernt.');">
<input type="hidden"
name="_token"
value="{{ csrf_token('delete_document_' ~ document.id) }}">
</td>
<button class="btn btn-sm btn-outline-danger">
Löschen
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="mt-4 small text-secondary">
Hinweis: Das Löschen eines Dokuments entfernt alle Versionen und
erfordert eine Aktualisierung des NDJSON-Indexes.
<div class="card bg-dark border-secondary text-light mt-4 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">Hinweis zum Dokument-Lifecycle</h5>
<div class="small text-light">
Änderungen an aktiven Versionen und Löschvorgänge wirken sich direkt auf den Wissensindex aus.
Zugewiesene Tags beeinflussen zusätzlich die semantische Routing-Ebene des Systems.
Dokumente mit schwachen oder fehlenden Tags sind oft ein guter Kandidat für fachliche Nachpflege.
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,8 +4,13 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">Neues Dokument</h1>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">Neues Dokument</h1>
<div class="small text-muted">
Neuer Upload mit initialer Version und anschließendem asynchronen Ingest.
</div>
</div>
<a href="{{ path('admin_documents') }}"
class="btn btn-sm btn-outline-secondary">
@@ -13,7 +18,49 @@
</a>
</div>
<div class="card bg-black border-secondary text-light">
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('info') %}
<div class="alert alert-info shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Warum ist der Titel wichtig?</h5>
<ul class="small mb-0">
<li>Der Titel wird später Teil des fachlichen Kontexts des Dokuments.</li>
<li>Ein präziser Titel verbessert Retrieval, Chunk-Einordnung und spätere Tag-Pflege.</li>
<li>Generische Titel wie <code>Dokument 1</code> oder nur Dateinamen sind deutlich schwächer.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Gute Beispiele</h5>
<ul class="small mb-0">
<li><code>Testomat 808 Technisches Datenblatt</code></li>
<li><code>Resthärte-Messung Produktübersicht</code></li>
<li><code>Indikator 300 Anwendung und Dosierung</code></li>
</ul>
</div>
</div>
</div>
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
@@ -22,31 +69,24 @@
name="_token"
value="{{ csrf_token('create_document') }}">
{# ============================= #}
{# Titel #}
{# ============================= #}
<div class="mb-4">
<label class="form-label">Titel</label>
<div class="alert alert-secondary small">
<strong>Hinweis zur Qualität:</strong><br>
Der Titel ist entscheidend für die semantische Einordnung
der erzeugten Chunks. Jeder Chunk erhält den Titel als Kontext,
wodurch Retrieval und Antwortqualität signifikant verbessert werden.<br><br>
Wird kein Titel angegeben, wird automatisch der Dateiname
verwendet (nicht empfohlen).
Verwende einen fachlich präzisen Titel, der Produkt, Thema oder Dokumenttyp klar beschreibt.
Wenn kein Titel angegeben wird, wird automatisch der Dateiname verwendet.
</div>
<input class="form-control bg-dark text-light border-secondary"
name="title"
placeholder="z. B. Sicherheitsdatenblatt Produkt XY">
</div>
value="{{ app.request.get('title') }}"
placeholder="z. B. Testomat 808 Technisches Datenblatt">
{# ============================= #}
{# Datei Upload #}
{# ============================= #}
<div class="form-text text-secondary">
Der Titel muss nicht lang sein, aber fachlich eindeutig.
</div>
</div>
<div class="mb-4">
<label class="form-label">Datei</label>
@@ -58,14 +98,22 @@
<div class="form-text text-secondary">
Unterstützte Formate: PDF, DOCX, TXT, MD.
Das Dokument wird versioniert gespeichert und anschließend
indexiert.
Nach dem Upload wird automatisch Version 1 erstellt und ein Ingest-Job gestartet.
</div>
</div>
{# ============================= #}
{# Submit #}
{# ============================= #}
<div class="card bg-dark border-secondary mb-4">
<div class="card-body">
<h6 class="text-info mb-3">Was passiert nach dem Speichern?</h6>
<ul class="small mb-0">
<li>Das Dokument wird versioniert gespeichert.</li>
<li>Die erste Version wird als aktuelle Version gesetzt.</li>
<li>Ein asynchroner Ingest-Job verarbeitet das Dokument für den Wissensindex.</li>
<li>Später können dem Dokument gezielt Tags zugewiesen werden.</li>
</ul>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-outline-info">
@@ -79,8 +127,7 @@
</div>
<div class="mt-4 small text-secondary">
Hinweis: Nach dem Upload wird automatisch eine neue Dokumentversion erstellt.
Die Indexierung erfolgt asynchron über einen Ingest-Job.
Hinweis: Ein sauber benanntes Dokument ist die beste Grundlage für gutes Retrieval und späteres präzises Tagging.
</div>
{% endblock %}
{% endblock %}

View File

@@ -4,10 +4,13 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
Neue Version
</h1>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">Neue Version</h1>
<div class="small text-muted">
Neue unveränderliche Version für ein bestehendes Dokument hochladen.
</div>
</div>
<a href="{{ path('admin_document_show', {id: document.id}) }}"
class="btn btn-sm btn-outline-secondary">
@@ -15,36 +18,99 @@
</a>
</div>
<div class="card bg-dark border-secondary mb-4 text-light">
<div class="card-body">
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="mb-3">
<strong>Dokument:</strong>
<span class="text-light">{{ document.title }}</span>
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('info') %}
<div class="alert alert-info shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Dokumentkontext</h5>
<div class="mb-2">
<strong>Dokument:</strong>
<span class="text-light">{{ document.title }}</span>
</div>
<div class="small text-secondary">
Eine neue Version erzeugt eine zusätzliche, unveränderliche Dokumentversion.
Die bestehende aktive Version bleibt zunächst unverändert.
</div>
</div>
<div class="small text-secondary">
Das Hochladen einer neuen Version erzeugt eine zusätzliche
unveränderliche Dokumentversion. Die Aktivierung erfolgt separat
und löst einen deterministischen Re-Ingest aus.
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Aktueller Stand</h5>
<div class="small mb-2">
<strong>Aktive Version:</strong>
{% if document.currentVersion %}
<span class="badge bg-info text-dark">
v{{ document.currentVersion.versionNumber }}
</span>
{% else %}
-
{% endif %}
</div>
<div class="small mb-2">
<strong>Vorhandene Versionen:</strong>
{{ document.versions|length }}
</div>
<div class="small">
<strong>Zugewiesene Tags:</strong>
{{ document.tags|length }}
</div>
</div>
</div>
</div>
<div class="card bg-black border-secondary text-light">
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Wichtig für den Lifecycle</h5>
<ul class="small mb-0">
<li>Der Upload erzeugt nur eine <strong>neue Version</strong>, aber aktiviert sie nicht automatisch.</li>
<li>Erst die spätere <strong>Aktivierung</strong> löst den deterministischen Re-Ingest aus.</li>
<li>Tags bleiben auf <strong>Dokumentebene</strong> bestehen und gelten weiterhin für das Dokument als Ganzes.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Gute Praxis</h5>
<ul class="small mb-0">
<li>Nur fachlich wirklich passende Nachfolgeversionen hochladen.</li>
<li>Kein anderes Thema oder anderes Produkt in dieselbe Dokumentlinie mischen.</li>
<li>Bei stark verändertem Fachinhalt später Tagging mitprüfen.</li>
</ul>
</div>
</div>
</div>
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden"
name="_token"
value="{{ csrf_token('create_document_version_' ~ document.id) }}">
{# ============================= #}
{# Datei Upload #}
{# ============================= #}
<div class="mb-4">
<label class="form-label">Datei auswählen</label>
@@ -54,15 +120,23 @@
required>
<div class="form-text text-secondary">
Unterstützte Formate: PDF, DOCX, TXT, MD.<br>
Die Datei wird versioniert gespeichert und mit einer
eindeutigen Checksum versehen.
Unterstützte Formate: PDF, DOCX, TXT, MD.
Die Datei wird versioniert gespeichert und mit einer eindeutigen Checksum versehen.
</div>
</div>
{# ============================= #}
{# Submit #}
{# ============================= #}
<div class="card bg-dark border-secondary mb-4">
<div class="card-body">
<h6 class="text-info mb-3">Was passiert nach dem Upload?</h6>
<ul class="small mb-0">
<li>Es wird eine neue, unveränderliche Dokumentversion angelegt.</li>
<li>Die aktive Version bleibt zunächst unverändert.</li>
<li>Ein Re-Ingest erfolgt erst nach späterer Aktivierung dieser Version.</li>
<li>Danach wird der Wissensindex deterministisch neu aufgebaut.</li>
</ul>
</div>
</div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<div class="d-flex justify-content-end">
@@ -71,16 +145,14 @@
</button>
</div>
{% endif %}
</form>
</div>
</div>
<div class="mt-4 small text-secondary">
Hinweis: Eine neue Version ersetzt nicht automatisch die aktive Version.
Erst nach Aktivierung wird ein Re-Ingest durchgeführt und der Index
neu aufgebaut.
Hinweis: Eine neue Version verbessert den Dokument-Lifecycle nur dann sauber, wenn sie fachlich wirklich zu diesem Dokument gehört.
Bei stark verändertem Inhalt sollten nach der späteren Aktivierung auch die Tags geprüft werden.
</div>
{% endblock %}
{% endblock %}

View File

@@ -4,116 +4,225 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">{{ document.title ?? 'Ein Fehler trat auf' }}</h1>
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">{{ document.title }}</h1>
<div class="small text-muted">
Detailansicht für Dokument, Versionen und Tag-Zuordnung.
</div>
</div>
<a href="{{ path('admin_documents') }}"
class="btn btn-sm btn-outline-secondary">
Zurück zur Übersicht
</a>
<div class="d-flex flex-wrap gap-2">
<a href="{{ path('admin_document_tags_edit', {id: document.id}) }}"
class="btn btn-sm btn-outline-info">
Tags bearbeiten
</a>
<a href="{{ path('admin_documents') }}"
class="btn btn-sm btn-outline-secondary">
Zurück zur Übersicht
</a>
</div>
</div>
{% if document %}
{# ============================= #}
{# Dokument-Meta #}
{# ============================= #}
<div class="card bg-dark border-secondary mb-5 text-light">
<div class="card-body">
<div class="mb-2">
<strong>Status:</strong>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</div>
<div class="mb-2">
<strong>Erstellt von:</strong>
{{ document.createdBy ? document.createdBy.email : '-' }}
</div>
<div class="mb-2">
<strong>Erstellt am:</strong>
{{ document.createdAt|date('d.m.Y H:i') }}
</div>
<div class="mb-2">
<strong>Aktive Version:</strong>
{% if document.currentVersion %}
<span class="badge bg-info text-dark">
v{{ document.currentVersion.versionNumber }}
</span>
{% else %}
-
{% endif %}
</div>
</div>
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
{# ============================= #}
{# Versionen #}
{# ============================= #}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Versionen</h2>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
class="btn btn-sm btn-outline-info">
Neue Version
</a>
{% endif %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
{% if document.versions is empty %}
{% for message in app.flashes('info') %}
<div class="alert alert-info shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="alert alert-secondary">
Keine Versionen vorhanden.
</div>
<div class="row g-4 mb-4">
{% else %}
<div class="card bg-black border-secondary">
<div class="col-lg-7">
<div class="card bg-dark border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">Dokument-Metadaten</h5>
<div class="row g-3">
<div class="col-md-6">
<div class="small text-muted mb-1">Status</div>
<div>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Aktive Version</div>
<div>
{% if document.currentVersion %}
<span class="badge bg-info text-dark">
v{{ document.currentVersion.versionNumber }}
</span>
{% else %}
-
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Erstellt von</div>
<div>{{ document.createdBy ? document.createdBy.email : '-' }}</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Erstellt am</div>
<div>{{ document.createdAt|date('d.m.Y H:i:s') }}</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Anzahl Versionen</div>
<div>{{ document.versions|length }}</div>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">Zugewiesene Tags</div>
<div>{{ document.tags|length }}</div>
</div>
</div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<hr class="border-secondary">
<div class="d-flex flex-wrap gap-2">
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
class="btn btn-sm btn-outline-info">
Neue Version
</a>
<form method="post"
action="{{ path('admin_document_delete', {id: document.id}) }}"
class="d-inline"
onsubmit="return confirm('Dokument wirklich löschen? Der Inhalt wird per Delete-Job aus dem Index entfernt.');">
<input type="hidden"
name="_token"
value="{{ csrf_token('delete_document_' ~ document.id) }}">
<button class="btn btn-sm btn-outline-danger">
Dokument löschen
</button>
</form>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card bg-dark border-secondary text-light h-100 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="text-info mb-0">Tags</h5>
<a href="{{ path('admin_document_tags_edit', {id: document.id}) }}"
class="btn btn-sm btn-outline-light">
Bearbeiten
</a>
</div>
{% if document.tags is empty %}
<div class="alert alert-secondary mb-0">
Diesem Dokument sind noch keine Tags zugewiesen.
</div>
{% else %}
<div class="d-flex flex-wrap gap-2">
{% for tag in document.tags %}
<span class="badge px-3 py-2
{% if tag.type == 'catalog_entity' %}
text-bg-info
{% elseif tag.type == 'sales_signal' %}
text-bg-warning
{% else %}
text-bg-secondary
{% endif %}">
{{ tag.label }}
</span>
{% endfor %}
</div>
<div class="small text-muted mt-3">
Tags steuern die semantische Routing-Ebene. Weise nur fachlich wirklich passende Tags zu.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h2 class="h5 mb-1">Versionen</h2>
<div class="small text-muted">
Beim Aktivieren einer Version wird automatisch ein Re-Ingest ausgelöst.
</div>
</div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
class="btn btn-sm btn-outline-info">
Neue Version
</a>
{% endif %}
</div>
{% if document.versions is empty %}
<div class="alert alert-secondary shadow-sm">
Keine Versionen vorhanden.
</div>
{% else %}
<div class="card bg-black border-secondary shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th>Version</th>
<th>Status</th>
<th>Ingest</th>
<th>Checksum</th>
<th>Erstellt von</th>
<th>Datum</th>
<th class="text-end">Aktionen</th>
<th style="width: 10%">Version</th>
<th style="width: 10%">Aktiv</th>
<th style="width: 14%">Ingest</th>
<th style="width: 18%">Checksum</th>
<th style="width: 16%">Erstellt von</th>
<th style="width: 14%">Datum</th>
<th class="text-end" style="width: 18%">Aktionen</th>
</tr>
</thead>
<tbody>
{% for version in document.versions %}
<tr>
<td>
<strong>v{{ version.versionNumber }}</strong>
{% if document.currentVersion and version.id == document.currentVersion.id %}
<div class="small text-info mt-1">Current</div>
{% endif %}
</td>
{# Aktivstatus #}
<td>
{% if version.isActive %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-dark border border-secondary">
Inaktiv
</span>
<span class="badge bg-dark border border-secondary">Inaktiv</span>
{% endif %}
</td>
{# Ingest Status #}
<td>
{% if version.ingestStatus == 'INDEXED' %}
<span class="badge bg-success">INDEXED</span>
@@ -125,99 +234,85 @@
<span class="badge bg-secondary">PENDING</span>
{% else %}
<span class="badge bg-dark border border-secondary">
{{ version.ingestStatus }}
</span>
{{ version.ingestStatus ?: '-' }}
</span>
{% endif %}
</td>
{# Checksum #}
<td class="small text-secondary">
{{ version.checksum ? version.checksum[:10] ~ '…' : '-' }}
{% if version.checksum %}
<code>{{ version.checksum[:12] ~ '…' }}</code>
{% else %}
-
{% endif %}
</td>
{# Created by #}
<td>
{{ version.createdBy ? version.createdBy.email : '-' }}
</td>
{# Date #}
<td class="small">
{{ version.createdAt|date('d.m.Y H:i') }}
{{ version.createdAt|date('d.m.Y H:i:s') }}
</td>
{# Aktionen #}
<td class="text-end">
{% if version.isActive %}
{% if version.ingestStatus in ['PENDING', 'FAILED'] and is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_version_ingest', {versionId: version.id}) }}"
class="d-inline"
onsubmit="return confirm('Ingest erneut starten?');">
<input type="hidden"
name="_token"
value="{{ csrf_token('ingest_version_' ~ version.id) }}">
<button class="btn btn-sm btn-outline-info">
Ingest starten
</button>
</form>
<div class="d-flex justify-content-end flex-wrap gap-2">
{% if version.isActive %}
{% if version.ingestStatus in ['PENDING', 'FAILED'] and is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_version_ingest', {versionId: version.id}) }}"
class="d-inline"
onsubmit="return confirm('Ingest erneut starten?');">
<input type="hidden"
name="_token"
value="{{ csrf_token('ingest_version_' ~ version.id) }}">
<button class="btn btn-sm btn-outline-info">
Ingest starten
</button>
</form>
{% else %}
<span class="small text-success align-self-center">
Keine Aktion nötig
</span>
{% endif %}
{% else %}
<span class="text-success small">
Bereits indexiert
</span>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_version_activate', {versionId: version.id}) }}"
class="d-inline"
onsubmit="return confirm('Diese Version aktivieren? Es wird ein Re-Ingest ausgelöst.');">
<input type="hidden"
name="_token"
value="{{ csrf_token('activate_version_' ~ version.id) }}">
<button class="btn btn-sm btn-outline-light">
Aktivieren
</button>
</form>
{% endif %}
{% endif %}
{% else %}
{% if is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
action="{{ path('admin_document_version_activate', {versionId: version.id}) }}"
class="d-inline"
onsubmit="return confirm('Diese Version aktivieren? Es wird ein Re-Ingest ausgelöst.');">
<input type="hidden"
name="_token"
value="{{ csrf_token('activate_version_' ~ version.id) }}">
<button class="btn btn-sm btn-outline-light">
Aktivieren
</button>
</form>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="mt-4 small text-secondary">
Hinweis: Beim Aktivieren einer Version wird automatisch ein Re-Ingest
durchgeführt. Der NDJSON-Index und der FAISS-Index werden deterministisch
neu aufgebaut.
</div>
{% else %}
<div class="alert alert-danger">
Dokument nicht gefunden.
</div>
{% endif %}
{% endblock %}
<div class="card bg-dark border-secondary text-light mt-4 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">Hinweis zum Lifecycle</h5>
<div class="small text-light">
Beim Aktivieren einer Version wird automatisch ein Re-Ingest durchgeführt.
Der NDJSON-Bestand und der Vektorindex werden deterministisch neu aufgebaut.
Wenn Tags zugewiesen sind, beeinflusst dieses Dokument zusätzlich die semantische Routing-Ebene.
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,81 +4,87 @@
{% block body %}
{# ============================================= #}
{# Tag-Rebuild Status (Echte Live-Anzeige) #}
{# ============================================= #}
<div id="rebuild-status" class="mb-5" style="min-height:54px"></div>
<div id="rebuild-status" class="mb-4">
{% if latestJob %}
<div class="alert alert-secondary shadow-sm mb-0">
Status wird geladen…
</div>
{% endif %}
</div>
<script>
let polling = null;
const statusBox = document.getElementById('rebuild-status');
const source = new EventSource("{{ path('admin_tags_rebuild_stream') }}");
function renderStatus(status) {
const el = document.getElementById('rebuild-status');
source.onmessage = function (event) {
const data = JSON.parse(event.data);
let html = '';
if (!status) {
el.innerHTML = '';
return;
}
if (status === 'RUNNING') {
el.innerHTML = `
<div class="alert alert-info d-flex justify-content-between align-items-center">
<div><strong>Dokument-Tag-Rebuild läuft…</strong></div>
if (data.status === '{{ statusRunning }}') {
html = `
<div class="alert alert-info shadow-sm d-flex justify-content-between align-items-center mb-0">
<div>
<strong>Dokument-Tag-Rebuild läuft</strong><br>
${data.startedAt ? 'Gestartet: ' + new Date(data.startedAt).toLocaleString() : ''}
</div>
<div class="spinner-border spinner-border-sm"></div>
</div>
`;
} else if (status === 'QUEUED') {
el.innerHTML = `
<div class="alert alert-secondary">
Dokument-Tag-Rebuild in Warteschlange
} else if (data.status === '{{ statusQueued }}') {
html = `
<div class="alert alert-secondary shadow-sm mb-0">
<strong>Dokument-Tag-Rebuild in Warteschlange</strong>
</div>
`;
} else if (status === 'COMPLETED') {
el.innerHTML = `
<div class="alert alert-success fw-bold">
Dokument-Tag-Rebuild erfolgreich abgeschlossen.
} else if (data.status === '{{ statusCompleted }}') {
html = `
<div class="alert alert-success shadow-sm mb-0">
<i class="bi bi-check-lg"></i> Dokument-Tag-Rebuild erfolgreich abgeschlossen
</div>
`;
stopPolling();
} else if (status === 'FAILED') {
el.innerHTML = `
<div class="alert alert-danger">
Dokument-Tag-Rebuild fehlgeschlagen.
} else if (data.status === '{{ statusFailed }}') {
html = `
<div class="alert alert-danger shadow-sm mb-0">
<strong>Dokument-Tag-Rebuild fehlgeschlagen</strong><br>
${data.error ? '<code>' + data.error + '</code>' : ''}
</div>
`;
stopPolling();
}
}
function checkStatus() {
fetch('{{ path('admin_tags_status') }}')
.then(r => r.json())
.then(data => renderStatus(data.status))
.catch(() => stopPolling());
}
statusBox.innerHTML = html;
};
function startPolling() {
polling = setInterval(checkStatus, 2000);
}
source.onerror = function () {
console.warn('SSE Verbindung verloren');
};
function stopPolling() {
if (polling) {
clearInterval(polling);
polling = null;
}
}
// Start polling sofort
checkStatus();
startPolling();
window.addEventListener('beforeunload', function () {
source.close();
});
</script>
{% for message in app.flashes('success') %}
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
Tags für Dokument
<span class="text-info">{{ document.title }}</span>
</h1>
<div>
<h1 class="h3 mb-1">
Tags für Dokument
<span class="text-info">{{ document.title }}</span>
</h1>
<div class="small text-muted">
Weise nur Tags zu, die den fachlichen Kern des Dokuments wirklich beschreiben.
</div>
</div>
<a href="{{ path('admin_documents') }}"
class="btn btn-sm btn-outline-light">
@@ -86,14 +92,40 @@
</a>
</div>
{# ============================================= #}
{# Bereits zugewiesene Tags #}
{# ============================================= #}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Hinweis für gutes Tagging</h5>
<div class="card bg-dark border-secondary mb-4">
<ul class="small mb-0">
<li><strong>Präzise statt breit:</strong> lieber produkt- oder themenscharfe Tags als allgemeine Oberbegriffe.</li>
<li><strong>Catalog Entity</strong> nur bei echten Produktfamilien, Katalogbegriffen oder klaren Entitäten.</li>
<li><strong>Generic</strong> nur als unterstützende Zusatzsemantik.</li>
<li><strong>Sales Signal</strong> sparsam und bewusst einsetzen, nicht als Ersatz für Fach-Tags.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Aktueller Stand</h5>
<div class="d-flex flex-wrap gap-2">
<span class="badge text-bg-dark border border-secondary">
Zugewiesen: {{ document.tags|length }}
</span>
<span class="badge text-bg-dark border border-secondary">
Verfügbar: {{ allTags|length }}
</span>
<span class="badge text-bg-dark border border-secondary">
Nicht zugewiesen: {{ allTags|length - document.tags|length }}
</span>
</div>
</div>
</div>
</div>
<div class="card bg-dark border-secondary mb-4 shadow-sm">
<div class="card-body">
<h5 class="mb-3">Zugewiesene Tags für: <span class="text-info ">{{ document.title }}</span></h5>
<h5 class="mb-3">Bereits zugewiesene Tags</h5>
{% if document.tags is empty %}
<div class="alert alert-secondary mb-0">
@@ -101,22 +133,26 @@
</div>
{% else %}
<div class="d-flex flex-wrap gap-2">
{% for tag in document.tags %}
<span class="badge bg-info text-dark px-3 py-2">
{{ tag.label }}
</span>
{% for tag in allTags %}
{% if tag in document.tags %}
<span class="badge px-3 py-2
{% if tag.type == 'catalog_entity' %}
text-bg-info
{% elseif tag.type == 'sales_signal' %}
text-bg-warning
{% else %}
text-bg-secondary
{% endif %}">
{{ tag.label }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{# ============================================= #}
{# Tag-Zuweisung Formular #}
{# ============================================= #}
<div class="card bg-black border-secondary">
<div class="card bg-black border-secondary shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">Tags zuweisen</h5>
@@ -128,38 +164,125 @@
name="_token"
value="{{ csrf_token('admin_document_tags_save_' ~ document.id) }}">
<div class="row">
{% for tag in allTags %}
<div class="col-md-2 mb-2">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="tag_ids[]"
value="{{ tag.id }}"
id="tag_{{ tag.id }}"
{% if tag in document.tags %}checked{% endif %}
>
<label class="form-check-label bg-info text-black badge"{% if tag not in document.tags %} style="opacity: .5;"{% endif %}
for="tag_{{ tag.id }}">
{{ tag.label }}
</label>
<div class="row g-4">
<div class="col-lg-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header bg-secondary-subtle text-dark fw-semibold">
Zugewiesene Tags
</div>
<div class="card-body">
<div class="row">
{% set hasAssigned = false %}
{% for tag in allTags %}
{% if tag in document.tags %}
{% set hasAssigned = true %}
<div class="col-md-6 mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="tag_ids[]"
value="{{ tag.id }}"
id="tag_{{ tag.id }}"
checked
>
<label class="form-check-label w-100" for="tag_{{ tag.id }}">
<span class="badge
{% if tag.type == 'catalog_entity' %}
text-bg-info
{% elseif tag.type == 'sales_signal' %}
text-bg-warning
{% else %}
text-bg-secondary
{% endif %}">
{{ tag.type }}
</span>
<span class="ms-2 fw-semibold">{{ tag.label }}</span>
{% if tag.description %}
<div class="small text-muted mt-1">{{ tag.description }}</div>
{% endif %}
</label>
</div>
</div>
{% endif %}
{% endfor %}
{% if not hasAssigned %}
<div class="col-12">
<div class="text-muted">
Noch keine Tags zugewiesen.
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="col-lg-6">
<div class="card bg-dark border-secondary h-100">
<div class="card-header bg-secondary-subtle text-dark fw-semibold">
Verfügbare Tags
</div>
<div class="card-body">
<div class="row">
{% set hasAvailable = false %}
{% for tag in allTags %}
{% if tag not in document.tags %}
{% set hasAvailable = true %}
<div class="col-md-6 mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
name="tag_ids[]"
value="{{ tag.id }}"
id="tag_{{ tag.id }}"
>
<label class="form-check-label w-100" for="tag_{{ tag.id }}">
<span class="badge
{% if tag.type == 'catalog_entity' %}
text-bg-info
{% elseif tag.type == 'sales_signal' %}
text-bg-warning
{% else %}
text-bg-secondary
{% endif %}">
{{ tag.type }}
</span>
<span class="ms-2">{{ tag.label }}</span>
{% if tag.description %}
<div class="small text-muted mt-1">{{ tag.description }}</div>
{% endif %}
</label>
</div>
</div>
{% endif %}
{% endfor %}
{% if not hasAvailable %}
<div class="col-12">
<div class="text-muted">
Keine weiteren Tags verfügbar.
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<hr class="border-secondary">
<button type="submit"
class="btn btn-sm btn-outline-info">
Speichern
</button>
<div class="d-flex justify-content-end">
<button type="submit"
class="btn btn-sm btn-outline-info">
Speichern
</button>
</div>
</form>

View File

@@ -4,8 +4,17 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"><i class="bi bi-terminal"></i> Indexierung (Ingest Jobs)</h1>
{% set latestJob = jobs is not empty ? jobs|first : null %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-terminal"></i> Indexierung (Ingest Jobs)
</h1>
<div class="small text-muted">
Übersicht über Reindex-, Dokument- und Aktivierungsjobs des Systems.
</div>
</div>
{% if is_granted('ROLE_SUPER_ADMIN') %}
<form method="post"
@@ -25,102 +34,215 @@
{% endif %}
</div>
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Was sieht man hier?</h5>
<ul class="small mb-0">
<li><strong>DOCUMENT</strong> verarbeitet ein einzelnes Dokument neu.</li>
<li><strong>DOCUMENT_VERSION_ACTIVATE</strong> zieht eine aktivierte Version deterministisch neu in den Index.</li>
<li><strong>DOCUMENT_DELETE</strong> entfernt Dokumentinhalt wieder sauber aus den Index-Artefakten.</li>
<li><strong>GLOBAL_REINDEX</strong> baut den Wissensindex vollständig neu auf und ist der stärkste Reparaturpfad.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Worauf achten?</h5>
<ul class="small mb-0">
<li><strong>RUNNING</strong> und <strong>QUEUED</strong> bedeuten: keine unnötigen parallelen Rebuilds starten.</li>
<li><strong>FAILED</strong> oder <strong>ABORTED</strong> direkt prüfen.</li>
<li>Bei inkonsistentem Indexzustand ist meist ein <strong>Global Reindex</strong> der richtige Reparaturschritt.</li>
</ul>
</div>
</div>
</div>
{% if latestJob %}
<div class="card bg-black border-secondary text-light mb-4 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
<div>
<div class="small text-muted mb-1">Letzter Job</div>
<div class="fw-semibold">
<a href="{{ path('admin_job_show', {id: latestJob.id}) }}"
class="text-light text-decoration-none">
{{ latestJob.id }}
</a>
</div>
</div>
<div>
<div class="small text-muted mb-1">Typ</div>
<span class="badge bg-info text-dark">{{ latestJob.type }}</span>
</div>
<div>
<div class="small text-muted mb-1">Status</div>
{% if latestJob.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif latestJob.status == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif latestJob.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif latestJob.status == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% elseif latestJob.status == 'ABORTED' %}
<span class="badge bg-dark border border-danger text-danger">ABORTED</span>
{% else %}
<span class="badge bg-dark border border-secondary">{{ latestJob.status }}</span>
{% endif %}
</div>
<div>
<div class="small text-muted mb-1">Gestartet</div>
<div class="small">
{{ latestJob.startedAt ? latestJob.startedAt|date('d.m.Y H:i:s') : '-' }}
</div>
</div>
<div>
<div class="small text-muted mb-1">Beendet</div>
<div class="small">
{{ latestJob.finishedAt ? latestJob.finishedAt|date('d.m.Y H:i:s') : 'läuft noch / offen' }}
</div>
</div>
</div>
{% if latestJob.errorMessage %}
<div class="alert alert-danger small mt-3 mb-0">
<strong>Fehler:</strong>
{{ latestJob.errorMessage|slice(0, 250) }}{% if latestJob.errorMessage|length > 250 %}{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if jobs is empty %}
<div class="alert alert-secondary">
<div class="alert alert-secondary shadow-sm">
Keine Ingest Jobs vorhanden.
</div>
{% else %}
<div class="card bg-black border-secondary">
<div class="card bg-black border-secondary shadow-sm">
<div class="card-body p-0">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th>Job-ID</th>
<th>Typ</th>
<th>Status</th>
<th>Dokument</th>
<th>Version</th>
<th>Gestartet</th>
<th>Beendet</th>
<th>Benutzer</th>
</tr>
</thead>
<tbody>
<div class="d-flex justify-content-between align-items-center px-3 py-3 border-bottom border-secondary flex-wrap gap-2">
<div>
<strong class="text-info">Vorhandene Jobs</strong>
<span class="small text-muted ms-2">{{ jobs|length }} Einträge</span>
</div>
{% for job in jobs %}
<div class="small text-muted">
Neueste Jobs stehen oben.
</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<td class="small">
<a href="{{ path('admin_job_show', {id: job.id}) }}"
class="text-light text-decoration-none">
{{ job.id }}
</a>
</td>
<td>
<span class="badge bg-info text-dark">
{{ job.type }}
</span>
</td>
<td>
{% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% else %}
<span class="badge bg-dark border border-secondary">
{{ job.status }}
</span>
{% endif %}
</td>
<td>
{% if job.documentId %}
<a href="{{ path('admin_document_show', {id: job.documentId}) }}"
class="text-light text-decoration-none">
{{ job.documentId }}
</a>
{% else %}
-
{% endif %}
</td>
<td>
{{ job.documentVersionId ?? '-' }}
</td>
<td class="small">
{{ job.startedAt ? job.startedAt|date('d.m.Y H:i:s') : '-' }}
</td>
<td class="small">
{{ job.finishedAt ? job.finishedAt|date('d.m.Y H:i:s') : '-' }}
</td>
<td class="small">
{{ job.startedBy ? job.startedBy.email : '-' }}
</td>
<th style="width: 18%">Job</th>
<th style="width: 14%">Typ</th>
<th style="width: 12%">Status</th>
<th style="width: 18%">Bezug</th>
<th style="width: 12%">Gestartet</th>
<th style="width: 12%">Beendet</th>
<th style="width: 14%">Benutzer</th>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center text-secondary py-4">
Keine Jobs gefunden.
</td>
</tr>
{% endfor %}
</thead>
<tbody>
</tbody>
</table>
{% for job in jobs %}
<tr>
<td class="small">
<div class="fw-semibold">
<a href="{{ path('admin_job_show', {id: job.id}) }}"
class="text-light text-decoration-none">
{{ job.id }}
</a>
</div>
{% if job.errorMessage %}
<div class="text-danger small mt-1"
title="{{ job.errorMessage }}">
{{ job.errorMessage|slice(0, 120) }}{% if job.errorMessage|length > 120 %}{% endif %}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-info text-dark">
{{ job.type }}
</span>
</td>
<td>
{% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% elseif job.status == 'ABORTED' %}
<span class="badge bg-dark border border-danger text-danger">ABORTED</span>
{% else %}
<span class="badge bg-dark border border-secondary">
{{ job.status }}
</span>
{% endif %}
</td>
<td class="small">
{% if job.documentId %}
<div>
<span class="text-muted">Dokument:</span>
<a href="{{ path('admin_document_show', {id: job.documentId}) }}"
class="text-light text-decoration-none">
{{ job.documentId }}
</a>
</div>
{% endif %}
{% if job.documentVersionId %}
<div class="mt-1">
<span class="text-muted">Version:</span>
{{ job.documentVersionId }}
</div>
{% endif %}
{% if not job.documentId and not job.documentVersionId %}
-
{% endif %}
</td>
<td class="small">
{{ job.startedAt ? job.startedAt|date('d.m.Y H:i:s') : '-' }}
</td>
<td class="small">
{{ job.finishedAt ? job.finishedAt|date('d.m.Y H:i:s') : 'offen' }}
</td>
<td class="small">
{{ job.startedBy ? job.startedBy.email : '-' }}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-secondary py-4">
Keine Jobs gefunden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
@@ -128,8 +250,8 @@
{% endif %}
<div class="mt-4 small text-secondary">
Hinweis: Während laufender Jobs (Status RUNNING) sollten keine
parallelen Reindex-Prozesse gestartet werden.
Hinweis: Während laufender Jobs (Status <strong>RUNNING</strong>) oder wartender Jobs (<strong>QUEUED</strong>)
sollten keine unnötigen parallelen Reindex-Prozesse gestartet werden.
</div>
{% endblock %}
{% endblock %}

View File

@@ -4,8 +4,18 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Ingest Job</h1>
{% set jobStatus = job.status|upper %}
{% set isActiveJob = jobStatus in ['QUEUED', 'RUNNING'] %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-terminal"></i> Ingest Job
</h1>
<div class="small text-muted">
Detailansicht für einen einzelnen Indexierungs- oder Rebuild-Job.
</div>
</div>
<a href="{{ path('admin_jobs') }}"
class="btn btn-sm btn-outline-secondary">
@@ -13,61 +23,134 @@
</a>
</div>
<div class="card bg-black border-secondary text-light">
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Einordnung</h5>
<ul class="small mb-0">
<li><strong>DOCUMENT</strong> verarbeitet ein einzelnes Dokument neu.</li>
<li><strong>DOCUMENT_VERSION_ACTIVATE</strong> aktiviert eine Version und zieht sie deterministisch neu in den Index.</li>
<li><strong>DOCUMENT_DELETE</strong> entfernt Dokumentinhalt wieder sauber aus dem Wissensbestand.</li>
<li><strong>GLOBAL_REINDEX</strong> baut den Gesamtindex vollständig neu auf.</li>
</ul>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Aktueller Zustand</h5>
<div class="d-flex flex-wrap gap-2">
{% if jobStatus == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif jobStatus == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif jobStatus == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif jobStatus == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% elseif jobStatus == 'ABORTED' %}
<span class="badge bg-dark border border-danger text-danger">ABORTED</span>
{% else %}
<span class="badge bg-dark border border-secondary">{{ jobStatus }}</span>
{% endif %}
{% if isActiveJob %}
<span class="badge text-bg-info">Polling aktiv</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<div class="mb-2">
<strong>ID:</strong>
<span class="small text-light">{{ job.id }}</span>
<div class="row g-4">
<div class="col-lg-6">
<div class="mb-3">
<div class="small text-muted mb-1">Job-ID</div>
<div class="fw-semibold small text-light">{{ job.id }}</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Typ</div>
<div>
<span class="badge bg-info text-dark">{{ job.type }}</span>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Status</div>
<div id="job-status-badge">
{% if jobStatus == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif jobStatus == 'QUEUED' %}
<span class="badge bg-secondary">QUEUED</span>
{% elseif jobStatus == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif jobStatus == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% elseif jobStatus == 'ABORTED' %}
<span class="badge bg-dark border border-danger text-danger">ABORTED</span>
{% else %}
<span class="badge bg-dark border border-secondary">{{ jobStatus }}</span>
{% endif %}
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Dokument</div>
<div>
{% if job.documentId %}
<a href="{{ path('admin_document_show', {id: job.documentId}) }}"
class="text-light text-decoration-none">
{{ job.documentId }}
</a>
{% else %}
-
{% endif %}
</div>
</div>
<div class="mb-0">
<div class="small text-muted mb-1">Dokumentversion</div>
<div>{{ job.documentVersionId ?? '-' }}</div>
</div>
</div>
<div class="col-lg-6">
<div class="mb-3">
<div class="small text-muted mb-1">Gestartet</div>
<div>
{{ job.startedAt ? job.startedAt|date('d.m.Y H:i:s') : '-' }}
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Beendet</div>
<div id="job-finished-at">
{{ job.finishedAt ? job.finishedAt|date('d.m.Y H:i:s') : '-' }}
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Gestartet von</div>
<div>{{ job.startedBy ? job.startedBy.email : '-' }}</div>
</div>
<div class="mb-0">
<div class="small text-muted mb-1">Polling</div>
<div class="small text-light">
{% if isActiveJob %}
Status wird automatisch aktualisiert.
{% else %}
Kein Live-Polling nötig.
{% endif %}
</div>
</div>
</div>
</div>
<div class="mb-2">
<strong>Typ:</strong>
<span class="badge bg-info text-dark">{{ job.type }}</span>
</div>
<div class="mb-2">
<strong>Status:</strong>
<span id="job-status-badge"></span>
</div>
<div class="mb-2">
<strong>Dokument:</strong>
{% if job.documentId %}
<a href="{{ path('admin_document_show', {id: job.documentId}) }}"
class="text-light text-decoration-none">
{{ job.documentId }}
</a>
{% else %}
-
{% endif %}
</div>
<div class="mb-2">
<strong>Version:</strong>
{{ job.documentVersionId ?? '-' }}
</div>
<div class="mb-2">
<strong>Gestartet:</strong>
{{ job.startedAt|date('d.m.Y H:i:s') }}
</div>
<div class="mb-2">
<strong>Beendet:</strong>
<span id="job-finished-at">
{{ job.finishedAt ? job.finishedAt|date('d.m.Y H:i:s') : '-' }}
</span>
</div>
<div class="mb-2">
<strong>Gestartet von:</strong>
{{ job.startedBy ? job.startedBy.email : '-' }}
</div>
{# Loader #}
<div id="job-loader"
class="mt-3 d-none">
class="mt-4 {% if not isActiveJob %}d-none{% endif %}">
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
<div>
@@ -79,10 +162,10 @@
</div>
</div>
{# Fehlerbereich #}
<div id="job-error"
class="alert alert-danger mt-3 {% if not job.errorMessage %}d-none{% endif %}">
class="alert alert-danger mt-4 {% if not job.errorMessage %}d-none{% endif %}">
{% if job.errorMessage %}
<strong>Fehler:</strong><br>
{{ job.errorMessage }}
{% endif %}
</div>
@@ -91,13 +174,13 @@
</div>
<div class="mt-4 small text-secondary">
Hinweis: Bei DOCUMENT_VERSION_ACTIVATE-Jobs wird ein vollständiger
NDJSON-Rebuild und FAISS-Reindex durchgeführt.
Hinweis: Bei <strong>DOCUMENT_VERSION_ACTIVATE</strong>-Jobs wird ein vollständiger
NDJSON-Rebuild und FAISS-Reindex durchgeführt. Bei <strong>GLOBAL_REINDEX</strong>
wird der gesamte Wissensindex neu aufgebaut.
</div>
<script>
(function () {
const statusUrl = {{ path('admin_job_status', {id: job.id})|json_encode|raw }};
const badgeWrap = document.getElementById('job-status-badge');
const finishedAtEl = document.getElementById('job-finished-at');
@@ -106,18 +189,26 @@
let timer = null;
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderBadge(status) {
const map = {
COMPLETED: 'bg-success',
QUEUED: 'bg-secondary',
RUNNING: 'bg-warning text-dark',
FAILED: 'bg-danger',
ABORTED: 'bg-dark'
ABORTED: 'bg-dark border border-danger text-danger'
};
const css = map[status] || 'bg-secondary';
badgeWrap.innerHTML =
`<span class="badge ${css}">${status}</span>`;
const css = map[status] || 'bg-dark border border-secondary';
badgeWrap.innerHTML = `<span class="badge ${css}">${escapeHtml(status || 'UNKNOWN')}</span>`;
}
function stopPolling() {
@@ -127,20 +218,39 @@
}
}
function renderError(message) {
if (!message) {
errorEl.classList.add('d-none');
errorEl.innerHTML = '';
return;
}
errorEl.classList.remove('d-none');
errorEl.innerHTML = `<strong>Fehler:</strong><br>${escapeHtml(message)}`;
}
async function poll() {
try {
const res = await fetch(statusUrl);
if (!res.ok) return;
const res = await fetch(statusUrl, {
headers: {
'Accept': 'application/json'
},
cache: 'no-store'
});
if (!res.ok) {
stopPolling();
return;
}
const data = await res.json();
const status = (data.status || '').toUpperCase();
const status = String(data.status || '').toUpperCase();
renderBadge(status);
finishedAtEl.textContent =
data.finishedAt
? new Date(data.finishedAt).toLocaleString('de-DE')
: '-';
finishedAtEl.textContent = data.finishedAt
? new Date(data.finishedAt).toLocaleString('de-DE')
: '-';
if (status === 'QUEUED' || status === 'RUNNING') {
loaderEl.classList.remove('d-none');
@@ -149,26 +259,22 @@
stopPolling();
}
if (status === 'FAILED' && data.errorMessage) {
errorEl.classList.remove('d-none');
errorEl.innerHTML =
`<strong>Fehler:</strong><br>${data.errorMessage}`;
if (status === 'FAILED' || status === 'ABORTED') {
renderError(data.errorMessage || '');
} else {
renderError('');
}
} catch (e) {
stopPolling();
}
}
// Initial render from server state
renderBadge("{{ job.status|upper }}");
renderBadge({{ jobStatus|json_encode|raw }});
if (["QUEUED", "RUNNING"].includes("{{ job.status|upper }}")) {
loaderEl.classList.remove('d-none');
if ({{ isActiveJob ? 'true' : 'false' }}) {
timer = setInterval(poll, 2000);
}
})();
</script>
{% endblock %}
{% endblock %}

View File

@@ -4,19 +4,24 @@
{% block body %}
{# ========================================================= #}
{# LIVE REBUILD STATUS (SSE) #}
{# ========================================================= #}
<div id="rebuild-status" class="mb-5">
<div class="alert alert-secondary shadow-sm">
Status wird geladen…
</div>
<div id="rebuild-status" class="mb-4">
{% if latestJob %}
<div class="alert alert-secondary shadow-sm mb-0">
Status wird geladen…
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-tag-fill"></i> Tag: {{ tag.label }}
</h1>
<div>
<h1 class="h3 mb-1">
<i class="bi bi-tag-fill"></i> Tag: {{ tag.label }}
</h1>
<div class="small text-muted">
Slug: <code>{{ tag.slug }}</code>
</div>
</div>
<a href="{{ path('admin_tags_index') }}"
class="btn btn-sm btn-outline-secondary">
@@ -24,7 +29,6 @@
</a>
</div>
<script>
const statusBox = document.getElementById('rebuild-status');
const source = new EventSource("{{ path('admin_tags_rebuild_stream') }}");
@@ -35,9 +39,9 @@
if (data.status === '{{ statusRunning }}') {
html = `
<div class="alert alert-info shadow-sm d-flex justify-content-between align-items-center">
<div class="alert alert-info shadow-sm d-flex justify-content-between align-items-center mb-0">
<div>
Tag-Rebuild läuft<br>
<strong>Tag-Rebuild läuft</strong><br>
${data.startedAt ? 'Gestartet: ' + new Date(data.startedAt).toLocaleString() : ''}
</div>
<div class="spinner-border spinner-border-sm"></div>
@@ -45,20 +49,20 @@
`;
} else if (data.status === '{{ statusQueued }}') {
html = `
<div class="alert alert-secondary shadow-sm">
Tag-Rebuild in Warteschlange
<div class="alert alert-secondary shadow-sm mb-0">
<strong>Tag-Rebuild in Warteschlange</strong>
</div>
`;
} else if (data.status === '{{ statusCompleted }}') {
html = `
<div class="alert alert-success shadow-sm">
<div class="alert alert-success shadow-sm mb-0">
<i class="bi bi-check-lg"></i> Tag-Rebuild erfolgreich abgeschlossen
</div>
`;
} else if (data.status === '{{ statusFailed }}') {
html = `
<div class="alert alert-danger shadow-sm">
Tag-Rebuild fehlgeschlagen<br>
<div class="alert alert-danger shadow-sm mb-0">
<strong>Tag-Rebuild fehlgeschlagen</strong><br>
${data.error ? '<code>' + data.error + '</code>' : ''}
</div>
`;
@@ -70,100 +74,179 @@
source.onerror = function () {
console.warn('SSE Verbindung verloren');
};
window.addEventListener('beforeunload', function () {
source.close();
});
</script>
{# ============================= #}
{# Flash Messages #}
{# ============================= #}
{% for message in app.flashes('success') %}
<div class="alert alert-success">
<div class="alert alert-success shadow-sm">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger">
<div class="alert alert-danger shadow-sm">
{{ message }}
</div>
{% endfor %}
{# ============================= #}
{# Tag → Dokumente #}
{# ============================= #}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row g-4">
<div class="col-lg-7">
<h5 class="text-info mb-3">Einordnung des Tags</h5>
<div class="mb-2">
{% if tag.type == 'catalog_entity' %}
<span class="badge text-bg-info">Catalog Entity</span>
{% elseif tag.type == 'sales_signal' %}
<span class="badge text-bg-warning">Sales Signal</span>
{% else %}
<span class="badge text-bg-secondary">Generic</span>
{% endif %}
</div>
<p class="small mb-2">
{{ tag.description ?: 'Keine Beschreibung hinterlegt.' }}
</p>
<p class="small text-muted mb-0">
Weise diesen Tag nur Dokumenten zu, die fachlich wirklich denselben Gegenstand,
dieselbe Produktfamilie oder denselben Anwendungsfall abbilden.
Zu breite Zuweisungen machen das Routing weicher.
</p>
</div>
<div class="col-lg-5">
<h5 class="text-info mb-3">Aktueller Stand</h5>
<div class="d-flex flex-wrap gap-2">
<span class="badge text-bg-dark border border-secondary">
Zugewiesen: {{ assignedDocIds|length }}
</span>
<span class="badge text-bg-dark border border-secondary">
Verfügbar: {{ documents|length }}
</span>
<span class="badge text-bg-dark border border-secondary">
Nicht zugewiesen: {{ documents|length - assignedDocIds|length }}
</span>
</div>
</div>
</div>
</div>
<form method="post">
<input type="hidden"
name="_token"
value="{{ csrf_token('assign_tag_' ~ tag.id) }}">
<div class="card bg-black border-secondary">
<div class="card-body p-0 row">
<div class=" col-lg-6">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead class="table-secondary text-dark">
<tr>
<th style="width:60px;"><i class="bi bi-three-dots"></i></th>
<th>Zugewiesene Dokumente</th>
</tr>
</thead>
<div class="row g-4">
<div class="col-lg-6">
<div class="card bg-black border-secondary shadow-sm h-100">
<div class="card-header bg-secondary-subtle text-dark fw-semibold">
Zugewiesene Dokumente
</div>
<tbody>
{% for doc in documents %}
{% if doc.id in assignedDocIds %}
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead class="table-secondary text-dark">
<tr>
<td>
<input type="checkbox"
name="documents[]"
value="{{ doc.id }}"
checked>
</td>
<td>
{{ doc.title }}
</td>
<th style="width: 60px;">
<i class="bi bi-check2-square"></i>
</th>
<th>Dokument</th>
</tr>
{% endif %}
{% endfor %}
</thead>
<tbody>
{% set hasAssigned = false %}
{% for doc in documents %}
{% if doc.id in assignedDocIds %}
{% set hasAssigned = true %}
<tr>
<td>
<input type="checkbox"
name="documents[]"
value="{{ doc.id }}"
checked>
</td>
<td class="fw-semibold">
{{ doc.title }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% if not hasAssigned %}
<tr>
<td colspan="2" class="text-center text-muted p-4">
Noch keine Dokumente zugewiesen.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<div class=" col-lg-6">
<table class="table table-dark table-striped table-hover mb-0 align-middle col-lg-6">
<thead class="table-secondary text-dark">
<tr>
<th style="width:60px;"><i class="bi bi-three-dots"></i></th>
<th>Nicht zugewiesene Dokumente</th>
</tr>
</thead>
</div>
<tbody>
{% for doc in documents %}
{% if doc.id not in assignedDocIds %}
<div class="col-lg-6">
<div class="card bg-black border-secondary shadow-sm h-100">
<div class="card-header bg-secondary-subtle text-dark fw-semibold">
Verfügbare Dokumente
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead class="table-secondary text-dark">
<tr>
<td>
<input type="checkbox"
name="documents[]"
value="{{ doc.id }}"
>
</td>
<td class="opacity-50">
{{ doc.title }}
</td>
<th style="width: 60px;">
<i class="bi bi-square"></i>
</th>
<th>Dokument</th>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</thead>
<tbody>
{% set hasUnassigned = false %}
{% for doc in documents %}
{% if doc.id not in assignedDocIds %}
{% set hasUnassigned = true %}
<tr>
<td>
<input type="checkbox"
name="documents[]"
value="{{ doc.id }}">
</td>
<td class="opacity-75">
{{ doc.title }}
</td>
</tr>
{% endif %}
{% endfor %}
{% if not hasUnassigned %}
<tr>
<td colspan="2" class="text-center text-muted p-4">
Keine weiteren aktiven Dokumente verfügbar.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<button class="btn btn-primary mt-3">
Speichern
</button>
<div class="d-flex justify-content-end mt-4">
<button class="btn btn-primary">
Zuweisungen speichern
</button>
</div>
</form>
{% endblock %}

View File

@@ -4,77 +4,52 @@
{% block body %}
{# ========================================================= #}
{# LIVE REBUILD STATUS (SSE) #}
{# ========================================================= #}
<div id="rebuild-status" class="mb-5">
<div id="rebuild-status" class="mb-4">
{% if latestJob %}
<div class="alert alert-secondary shadow-sm">
<div class="alert alert-secondary shadow-sm mb-0">
Status wird geladen…
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0"><i class="bi bi-tag-fill"></i> Tag-Management</h1>
<h1 class="h3 mb-0">
<i class="bi bi-tag-fill"></i> Tag-Management
</h1>
</div>
{# ========================================================= #}
{# TAG SYSTEM DESCRIPTION #}
{# ========================================================= #}
<div class="card bg-dark border-secondary text-light mb-4 shadow-sm">
<div class="card-body row">
<div class="card-body row g-4">
<div class="col-lg-6">
<h5 class="text-info mb-3">Was machen Tags im System?</h5>
<p class="small text-light mb-2">
Tags dienen als semantische Routing-Ebene innerhalb des RAG-Systems.
Sie strukturieren Dokumente thematisch und beeinflussen,
welche Inhalte bei einer Nutzeranfrage priorisiert werden.
Tags sind die semantische Routing-Ebene innerhalb des Systems.
Sie helfen dabei, thematisch passende Dokumenträume schneller zu erkennen
und gute Retrieval-Kandidaten zu priorisieren.
</p>
<ul class="small text-light mb-3">
<li>
Tags werden Dokumenten manuell zugewiesen.
</li>
<li>
Beim Rebuild wird aus allen Tags eine eigene
<code>tags.ndjson</code> erzeugt.
</li>
<li>
Zusätzlich wird ein separater Vektorindex
(<code>vector_tags.index</code>) aufgebaut.
</li>
<li>
Bei einer Anfrage erfolgt zunächst ein Tag-Matching,
danach wird das Chunk-Retrieval entsprechend gewichtet.
</li>
<ul class="small text-light mb-0">
<li>Tags werden Dokumenten manuell zugewiesen.</li>
<li>Beim Rebuild wird aus den aktiven Tag-Zuordnungen eine <code>tags.ndjson</code> erzeugt.</li>
<li>Zusätzlich wird ein eigener Tag-Vektorindex (<code>vector_tags.index</code>) gebaut.</li>
<li>Bei Anfragen erfolgt zunächst ein semantisches Tag-Matching, danach das eigentliche Chunk-Retrieval.</li>
</ul>
</div>
<div class="col-lg-6">
<h6 class="text-info mt-3">Wie werden Tags bewertet?</h6>
<h5 class="text-info mb-3">Was ist gutes Tagging?</h5>
<p class="small text-light mb-2">
Die Bewertung erfolgt über einen eigenen Vektor-Similarity-Score
im Tag-Index. Das System berechnet:
</p>
<ul class="small text-light">
<li>
Ähnlichkeit zwischen Nutzeranfrage und Tag-Embedding
</li>
<li>
Top-K Treffer im Tag-Index
</li>
<li>
Gewichtete Übergabe an das Chunk-Retrieval
</li>
<ul class="small text-light mb-3">
<li><strong>Präzise statt generisch:</strong> lieber <code>Produktnamen</code> als <code>Gerät</code>.</li>
<li><strong>Fachlich sauber:</strong> Tags sollen echte Produktfamilien, Anwendungsfälle oder Entitäten abbilden.</li>
<li><strong>Wenig Überschneidung:</strong> keine unnötig breiten oder doppeldeutigen Tags.</li>
<li><strong>Bewusst typisieren:</strong> <code>catalog_entity</code> für echte Katalog-/Entity-Tags, <code>generic</code> nur für allgemeine Zusatzsemantik.</li>
</ul>
<p class="small text-light mt-2 mb-0">
Tags wirken somit als semantischer Verstärker.
Sie ersetzen kein Chunk-Retrieval, sondern steuern dessen Priorisierung.
<p class="small text-warning mb-0">
Zu breite Tags wie „Produkt“, „System“ oder „Gerät“ machen das Routing weicher
und bringen meist weniger Nutzen als präzise fachliche Tags.
</p>
</div>
</div>
@@ -90,9 +65,9 @@
if (data.status === '{{ statusRunning }}') {
html = `
<div class="alert alert-info shadow-sm d-flex justify-content-between align-items-center">
<div class="alert alert-info shadow-sm d-flex justify-content-between align-items-center mb-0">
<div>
Tag-Rebuild läuft<br>
<strong>Tag-Rebuild läuft</strong><br>
${data.startedAt ? 'Gestartet: ' + new Date(data.startedAt).toLocaleString() : ''}
</div>
<div class="spinner-border spinner-border-sm"></div>
@@ -100,20 +75,20 @@
`;
} else if (data.status === '{{ statusQueued }}') {
html = `
<div class="alert alert-secondary shadow-sm">
Tag-Rebuild in Warteschlange
<div class="alert alert-secondary shadow-sm mb-0">
<strong>Tag-Rebuild in Warteschlange</strong>
</div>
`;
} else if (data.status === '{{ statusCompleted }}') {
html = `
<div class="alert alert-success shadow-sm">
<div class="alert alert-success shadow-sm mb-0">
<i class="bi bi-check-lg"></i> Tag-Rebuild erfolgreich abgeschlossen
</div>
`;
} else if (data.status === '{{ statusFailed }}') {
html = `
<div class="alert alert-danger shadow-sm">
Tag-Rebuild fehlgeschlagen<br>
<div class="alert alert-danger shadow-sm mb-0">
<strong>Tag-Rebuild fehlgeschlagen</strong><br>
${data.error ? '<code>' + data.error + '</code>' : ''}
</div>
`;
@@ -125,11 +100,12 @@
source.onerror = function () {
console.warn('SSE Verbindung verloren');
};
window.addEventListener('beforeunload', function () {
source.close();
});
</script>
{# ========================================================= #}
{# Create Tag Card #}
{# ========================================================= #}
<div class="card bg-black border-secondary text-light mb-4 shadow-sm">
<div class="card-body">
<h5 class="text-info mb-3">Neuen Tag hinzufügen</h5>
@@ -153,24 +129,26 @@
required/>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Beschreibung</label>
<input class="form-control form-control-sm"
name="description"
placeholder="Semantische Beschreibung des Tags"
required/>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select">
<option value="generic">Generic</option>
<option value="catalog_entity">Catalog Entity</option>
<option value="sales_signal">Sales Signal</option>
<div class="col-md-3">
<label class="form-label small text-muted">Typ</label>
<select name="type" class="form-select form-select-sm">
{% for choiceLabel, choiceValue in tagTypeChoices %}
<option value="{{ choiceValue }}"
{% if choiceValue == 'generic' %}selected{% endif %}>
{{ choiceLabel }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-grid align-items-end">
<div class="col-md-3">
<label class="form-label small text-muted">Beschreibung</label>
<input class="form-control form-control-sm"
name="description"
placeholder="Optional: fachlicher Kontext des Tags"/>
</div>
<div class="col-12 d-grid d-md-flex justify-content-md-end">
<button class="btn btn-sm btn-outline-info">
Anlegen
</button>
@@ -179,66 +157,85 @@
</div>
</div>
{# ========================================================= #}
{# Tag Table #}
{# ========================================================= #}
<div class="card bg-black border-secondary text-light shadow-sm">
<div class="card-body">
<div class="mb-3">
<strong class="text-info">Vorhandene Tags:</strong>
<span class="text-muted small ms-2">
{{ tags|length }} Einträge
</span>
<div class="mb-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<strong class="text-info">Vorhandene Tags:</strong>
<span class="text-muted small ms-2">
{{ tags|length }} Einträge
</span>
</div>
<div class="small text-muted">
Dokumentanzahl bezieht sich auf aktive Dokumente.
</div>
</div>
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead class="table-secondary text-dark">
<tr>
<th style="width: 25%">Label</th>
<th style="width: 25%">Slug</th>
<th style="width: 35%">Beschreibung</th>
<th class="text-end" style="width: 15%">Aktion</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead class="table-secondary text-dark">
<tr>
<td class="fw-semibold">{{ tag.label }}</td>
<td><code>{{ tag.slug }}</code></td>
<td>{{ tag.description ?: '-' }}</td>
<td class="text-end">
<a href="{{ path('admin_tags_assign', { id: tag.id }) }}"
class="btn btn-sm btn-outline-info me-2">
Zuweisen
</a>
<form method="post"
action="{{ path('admin_tags_delete', {id: tag.id}) }}"
style="display:inline-block;">
<input type="hidden"
name="_token"
value="{{ csrf_token('admin_tag_delete_' ~ tag.id) }}"/>
<button class="btn btn-sm btn-outline-danger"
onclick="return confirm('Tag wirklich löschen? Zuweisungen werden entfernt.')">
Löschen
</button>
</form>
</td>
<th style="width: 18%">Label</th>
<th style="width: 18%">Slug</th>
<th style="width: 14%">Typ</th>
<th style="width: 10%">Aktive Dokumente</th>
<th style="width: 25%">Beschreibung</th>
<th class="text-end" style="width: 15%">Aktion</th>
</tr>
{% else %}
<tr>
<td colspan="4" class="p-4 text-center text-muted">
Noch keine Tags vorhanden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</thead>
<tbody>
{% for tag in tags %}
{% set activeDocumentCount = documentCountByTagId[tag.id.toRfc4122] ?? 0 %}
<tr>
<td class="fw-semibold">{{ tag.label }}</td>
<td><code>{{ tag.slug }}</code></td>
<td>
{% if tag.type == 'catalog_entity' %}
<span class="badge text-bg-info">Catalog Entity</span>
{% elseif tag.type == 'sales_signal' %}
<span class="badge text-bg-warning">Sales Signal</span>
{% else %}
<span class="badge text-bg-secondary">Generic</span>
{% endif %}
</td>
<td>
<span class="badge text-bg-dark border border-secondary">
{{ activeDocumentCount }}
</span>
</td>
<td>{{ tag.description ?: '-' }}</td>
<td class="text-end">
<a href="{{ path('admin_tags_assign', { id: tag.id }) }}"
class="btn btn-sm btn-outline-info me-2">
Zuweisen
</a>
<form method="post"
action="{{ path('admin_tags_delete', {id: tag.id}) }}"
style="display:inline-block;">
<input type="hidden"
name="_token"
value="{{ csrf_token('admin_tag_delete_' ~ tag.id) }}"/>
<button class="btn btn-sm btn-outline-danger"
onclick="return confirm('Tag wirklich löschen? Zuweisungen werden entfernt.')">
Löschen
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-4 text-center text-muted">
Noch keine Tags vorhanden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>