add health check

This commit is contained in:
team 1
2026-02-18 10:26:44 +01:00
parent b1e1fe082e
commit 9aa2bbcda4
7 changed files with 323 additions and 108 deletions

View File

@@ -172,3 +172,9 @@ services:
arguments: arguments:
$ndJsonPath: '%mto.knowledge.ndjson%' $ndJsonPath: '%mto.knowledge.ndjson%'
$indexMetaPath: '%mto.knowledge.index_meta%' $indexMetaPath: '%mto.knowledge.index_meta%'
App\Vector\VectorIndexHealthService:
arguments:
$indexNdjsonPath: '%kernel.project_dir%/var/knowledge/index.ndjson'
$vectorIndexPath: '%kernel.project_dir%/var/knowledge/vector.index'
$vectorMetaPath: '%kernel.project_dir%/var/knowledge/vector.index.meta.json'

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Vector\VectorIndexHealthService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'mto:agent:vector:health',
description: 'Health-Check für NDJSON/FAISS Konsistenz'
)]
final class VectorHealthCheckCommand extends Command
{
public function __construct(
private VectorIndexHealthService $health
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$result = $this->health->check();
$output->writeln(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return str_starts_with($result['status'], 'OK')
? Command::SUCCESS
: Command::FAILURE;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Admin;
use App\Index\IndexMetaManager; use App\Index\IndexMetaManager;
use App\Ingest\IngestFlow; use App\Ingest\IngestFlow;
use App\Vector\VectorIndexHealthService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -12,13 +13,15 @@ use Symfony\Component\Routing\Attribute\Route;
final class DashboardController extends AbstractController final class DashboardController extends AbstractController
{ {
#[Route('/admin', name: 'admin_dashboard')] #[Route('/admin', name: 'admin_dashboard')]
public function index(): Response public function index(VectorIndexHealthService $health): Response
{ {
return $this->render('admin/dashboard/index.html.twig'); return $this->render('admin/dashboard/index.html.twig', [
'vectorHealth' => $health->check()
]);
} }
#[Route('/admin/dashboard', name: 'admin_dashboard')] #[Route('/admin/dashboard', name: 'admin_dashboard')]
public function dashboard(IndexMetaManager $metaManager): Response public function dashboard(IndexMetaManager $metaManager,VectorIndexHealthService $health): Response
{ {
$chunkCount = $metaManager->getRuntimeChunkCount(); $chunkCount = $metaManager->getRuntimeChunkCount();
$limit = IngestFlow::CHUNK_LIMIT_HARD; $limit = IngestFlow::CHUNK_LIMIT_HARD;
@@ -26,6 +29,7 @@ final class DashboardController extends AbstractController
return $this->render('admin/dashboard/index.html.twig', [ return $this->render('admin/dashboard/index.html.twig', [
'chunkCount' => $chunkCount, 'chunkCount' => $chunkCount,
'chunkLimit' => $limit, 'chunkLimit' => $limit,
'vectorHealth' => $health->check(),
]); ]);
} }
} }

View File

@@ -30,7 +30,7 @@ final class IndexNdjsonInspector
{ {
if (!is_file($this->metaPath)) { if (!is_file($this->metaPath)) {
return [ return [
'error' => 'index_meta.json nicht gefunden', 'error' => 'index_meta.json nicht gefunden. Bitte laden Sie min. ein Dokument in das System.',
'path' => $this->metaPath, 'path' => $this->metaPath,
]; ];
} }
@@ -67,7 +67,7 @@ final class IndexNdjsonInspector
if (!is_file($this->ndjsonPath)) { if (!is_file($this->ndjsonPath)) {
return [ return [
'error' => 'index.ndjson nicht gefunden', 'error' => 'index.ndjson nicht gefunden. Bitte laden Sie min. ein Dokument in das System.',
'path' => $this->ndjsonPath, 'path' => $this->ndjsonPath,
'items' => [], 'items' => [],
]; ];

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Vector;
final class VectorIndexHealthService
{
public function __construct(
private string $indexNdjsonPath,
private string $vectorIndexPath,
private string $vectorMetaPath
) {}
public function check(): array
{
$ndjsonExists = is_file($this->indexNdjsonPath);
$vectorExists = is_file($this->vectorIndexPath);
$metaExists = is_file($this->vectorMetaPath);
$ndjsonChunkCount = 0;
if ($ndjsonExists) {
$h = @fopen($this->indexNdjsonPath, 'r');
if ($h !== false) {
while (($line = fgets($h)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$data = json_decode($line, true);
if (is_array($data) && !empty($data['chunk_id']) && !empty($data['text'])) {
$ndjsonChunkCount++;
}
}
fclose($h);
}
}
$vectorChunkCount = 0;
if ($metaExists) {
$meta = json_decode((string) file_get_contents($this->vectorMetaPath), true);
if (is_array($meta)) {
$vectorChunkCount = count($meta);
}
}
$status = $this->determineStatus(
$ndjsonChunkCount,
$vectorExists,
$metaExists,
$vectorChunkCount
);
return [
'ndjson_exists' => $ndjsonExists,
'ndjson_chunk_count' => $ndjsonChunkCount,
'vector_exists' => $vectorExists,
'meta_exists' => $metaExists,
'vector_chunk_count' => $vectorChunkCount,
'status' => $status,
];
}
private function determineStatus(
int $ndjsonChunkCount,
bool $vectorExists,
bool $metaExists,
int $vectorChunkCount
): string {
if ($ndjsonChunkCount === 0 && !$vectorExists && !$metaExists) {
return 'OK_EMPTY';
}
if ($ndjsonChunkCount > 0 && $vectorExists && $metaExists && $vectorChunkCount === $ndjsonChunkCount) {
return 'OK';
}
if ($ndjsonChunkCount === 0 && ($vectorExists || $metaExists)) {
return 'INCONSISTENT_STALE_VECTOR';
}
if ($ndjsonChunkCount > 0 && (!$vectorExists || !$metaExists)) {
return 'INCONSISTENT_MISSING_VECTOR';
}
if ($ndjsonChunkCount !== $vectorChunkCount) {
return 'INCONSISTENT_COUNT_MISMATCH';
}
return 'UNKNOWN';
}
}

View File

@@ -1,55 +1,177 @@
{% extends 'admin/base.html.twig' %} {% extends 'admin/base.html.twig' %}
{% block title %}Admin Dashboard{% endblock %} {% block title %}System Dashboard{% endblock %}
{% block body %} {% block body %}
<div class="container-fluid">
{# ===================================================== #}
{# HEADER #}
{# ===================================================== #}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Dashboard</h1> <h1 class="h3 mb-0">System Overview</h1>
<span class="badge bg-secondary">RAG Enterprise</span>
</div> </div>
{# ============================= #} {# ===================================================== #}
{# USER INFO CARD #} {# GOVERNANCE BLOCK #}
{# ============================= #} {# ===================================================== #}
<div class="card bg-black border-secondary mb-4 text-light"> <div class="card bg-black border-secondary text-light mb-4">
<div class="card-body"> <div class="card-body">
<h5 class="text-info mb-3">System Benutzer</h5> <h5 class="text-info mb-3">System Governance</h5>
<div class="mb-2"> <div class="row">
<strong>User:</strong> <div class="col-md-6">
<strong>Current User:</strong><br>
{{ app.user.userIdentifier }} {{ app.user.userIdentifier }}
</div> </div>
<div class="mb-2"> <div class="col-md-6">
<strong>Rollen:</strong> <strong>Roles:</strong><br>
{{ app.user.roles|join(', ') }} {{ app.user.roles|join(', ') }}
</div> </div>
</div>
</div> </div>
</div> </div>
{# ============================= #}
{# SYSTEM RESET CARD #} {# ===================================================== #}
{# ============================= #} {# VECTOR INFRASTRUCTURE #}
{# ===================================================== #}
{% 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')
%}
<div class="card bg-black border-secondary text-light mb-4">
<div class="card-body">
<h5 class="text-info mb-3">Vector Infrastructure</h5>
<div class="row mb-3">
<div class="col-md-4">
<strong>Status</strong><br>
<span class="badge {{ badgeClass }}">
{{ status }}
</span>
</div>
<div class="col-md-4">
<strong>NDJSON Chunks</strong><br>
{{ vectorHealth.ndjson_chunk_count|number_format(0, ',', '.') }}
</div>
<div class="col-md-4">
<strong>Vector Index Chunks</strong><br>
{{ vectorHealth.vector_chunk_count|number_format(0, ',', '.') }}
</div>
</div>
{% if status starts with 'OK' %}
<div class="small text-success">
Infrastructure is consistent.
</div>
{% elseif status == 'INCONSISTENT_MISSING_VECTOR' %}
<div class="small text-warning">
Vector index missing. Rebuild recommended.
</div>
{% else %}
<div class="small text-danger">
Index inconsistency detected. Immediate review required.
</div>
{% endif %}
</div>
</div>
{% endif %}
{# ===================================================== #}
{# KNOWLEDGE CAPACITY #}
{# ===================================================== #}
{% set percent = chunkLimit > 0 ? (chunkCount / chunkLimit * 100)|round(1) : 0 %}
<div class="card bg-black border-secondary text-light mb-4">
<div class="card-body">
<h5 class="text-info mb-3">Knowledge Capacity</h5>
<div class="mb-2">
<strong>Chunks:</strong>
{{ chunkCount|number_format(0, ',', '.') }}
/
{{ chunkLimit|number_format(0, ',', '.') }}
</div>
<div class="progress bg-dark" style="height: 20px;">
<div
class="progress-bar
{% if percent >= 95 %}
bg-danger
{% elseif percent >= 85 %}
bg-warning text-dark
{% else %}
bg-success
{% endif %}"
role="progressbar"
style="width: {{ percent }}%;"
aria-valuenow="{{ percent }}"
aria-valuemin="0"
aria-valuemax="100"
>
{{ percent }}%
</div>
</div>
<div class="mt-3 small text-secondary">
System optimized for maximum
{{ chunkLimit|number_format(0, ',', '.') }} chunks.
{% if percent >= 95 %}
<br><strong class="text-danger">Capacity limit nearly reached.</strong>
{% endif %}
</div>
</div>
</div>
{# ===================================================== #}
{# CRITICAL OPERATIONS (SUPER ADMIN ONLY) #}
{# ===================================================== #}
{% if is_granted('ROLE_SUPER_ADMIN') %} {% if is_granted('ROLE_SUPER_ADMIN') %}
<div class="card bg-black border-danger mb-4 text-light"> <div class="card bg-black border-danger text-light">
<div class="card-body"> <div class="card-body">
<h5 class="text-danger mb-3">System Reset</h5> <h5 class="text-danger mb-3">Critical Operations</h5>
<div class="small text-light mb-3"> <div class="small mb-3">
Der Reset entfernt: Full system reset removes:
<ul class="mb-2"> <ul>
<li>Alle Dokumente und Versionen</li> <li>All documents & versions</li>
<li>Den gesamten NDJSON-Index</li> <li>NDJSON index</li>
<li>Den FAISS-Vektorindex</li> <li>FAISS vector index</li>
<li>Alle Ingest-Jobs</li> <li>All ingest jobs</li>
</ul> </ul>
Diese Aktion ist <strong>irreversibel</strong>. <strong>This action is irreversible.</strong>
</div> </div>
{% for label, messages in app.flashes %} {% for label, messages in app.flashes %}
@@ -62,7 +184,7 @@
<form method="post" <form method="post"
action="{{ path('admin_document_reset') }}" action="{{ path('admin_document_reset') }}"
onsubmit="return confirm('Wirklich das gesamte System zurücksetzen? Diese Aktion ist endgültig.');"> onsubmit="return confirm('Confirm full system reset? This cannot be undone.');">
<input type="hidden" <input type="hidden"
name="_token" name="_token"
@@ -70,8 +192,9 @@
<button type="submit" <button type="submit"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
System vollständig zurücksetzen Execute Full System Reset
</button> </button>
</form> </form>
</div> </div>
@@ -79,52 +202,6 @@
{% endif %} {% endif %}
{# ============================= #}
{# KNOWLEDGE INDEX STATUS #}
{# ============================= #}
{% set percent = chunkLimit > 0 ? (chunkCount / chunkLimit * 100)|round(1) : 0 %}
<div class="card bg-black border-secondary text-light">
<div class="card-body">
<h5 class="text-info mb-3">Knowledge Index Status</h5>
<div class="mb-2">
<strong>Chunks:</strong>
{{ chunkCount|number_format(0, ',', '.') }}
/
{{ chunkLimit|number_format(0, ',', '.') }}
</div>
<div class="progress bg-dark" style="height: 18px;">
<div
class="progress-bar
{% if percent >= 95 %}
bg-danger
{% elseif percent >= 85 %}
bg-warning text-dark
{% else %}
bg-success
{% endif %}"
role="progressbar"
aria-valuenow="{{ percent }}"
aria-valuemin="0"
aria-valuemax="100"
style="width: {{ percent }}%;"
>
{{ percent }}%
</div>
</div>
<div class="mt-3 small text-secondary">
System ist für maximal {{ chunkLimit|number_format(0, ',', '.') }} Chunks optimiert.
{% if percent >= 95 %}
<br><strong class="text-danger">Kapazitätsgrenze nahezu erreicht.</strong>
{% endif %}
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -21,10 +21,10 @@
<div> <div>
<strong>Strukturabweichung erkannt.</strong> <strong>Strukturabweichung erkannt.</strong>
Die aktuelle Indexstruktur entspricht nicht dem aktiven Profil. Die aktuelle Indexstruktur entspricht nicht dem aktiven Profil.
Eine globale Neuindizierung ist erforderlich. Eine globale Neuindizierung ist erforderlich oder Sie haben kein indexiertes Dokument im System.
</div> </div>
<a href="{{ path('admin_jobs') }}" <a href="{{ path('admin_jobs') }}"
class="btn btn-sm btn-outline-light"> class="btn btn-sm btn-outline-danger">
Global Reindex starten Global Reindex starten
</a> </a>
</div> </div>