add system prompt and chunks index views and edit

This commit is contained in:
team2
2026-02-15 21:33:31 +01:00
parent 59e48242b5
commit 3416678cf4
12 changed files with 743 additions and 7 deletions

View File

@@ -125,3 +125,8 @@ services:
$scoringVersion: '%mto.index.scoring_version%' $scoringVersion: '%mto.index.scoring_version%'
$indexFormat: 'ndjson' $indexFormat: 'ndjson'
$vectorBackend: 'faiss' $vectorBackend: 'faiss'
App\Service\Admin\IndexNdjsonInspector:
arguments:
$ndJsonPath: '%mto.vector.data.ndjson.path%'
$indexMetaPath: '%mto.vector.data.vector_index_meta_json.path%'

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260215193707 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE system_prompt (id BINARY(16) NOT NULL, version INT NOT NULL, content LONGTEXT NOT NULL, active TINYINT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE system_prompt');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260215201902 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE system_prompt ADD comment VARCHAR(255) DEFAULT NULL, ADD updated_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE system_prompt DROP comment, DROP updated_at');
}
}

View File

@@ -5,14 +5,14 @@ declare(strict_types=1);
namespace App\Agent; namespace App\Agent;
use App\Context\ContextService; use App\Context\ContextService;
use App\Context\UrlAnalyzer; use App\Repository\SystemPromptRepository;
use DateTimeImmutable; use DateTimeImmutable;
final class PromptBuilder final readonly class PromptBuilder
{ {
public function __construct( public function __construct(
private readonly ContextService $contextService, private ContextService $contextService,
private readonly UrlAnalyzer $urlAnalyzer, private SystemPromptRepository $systemPromptRepository,
) )
{ {
} }
@@ -25,6 +25,7 @@ final class PromptBuilder
* @param string $urlContent * @param string $urlContent
* @param string[] $knowledgeChunks * @param string[] $knowledgeChunks
* @param bool $fullContext * @param bool $fullContext
* @return string
*/ */
public function build( public function build(
string $prompt, string $prompt,
@@ -39,7 +40,8 @@ final class PromptBuilder
// ------------------------------------------------------------ // ------------------------------------------------------------
// 1) SYSTEM INSTRUCTIONS // 1) SYSTEM INSTRUCTIONS
// ------------------------------------------------------------ // ------------------------------------------------------------
$systemLines = [ //ToDO: remove old systemLines
/*$systemLines = [
'You are a conversational AI assistant.', 'You are a conversational AI assistant.',
'Respond clearly, precisely, and in context of the ongoing conversation.', 'Respond clearly, precisely, and in context of the ongoing conversation.',
'The conversation context is authoritative and must be respected.', 'The conversation context is authoritative and must be respected.',
@@ -66,7 +68,15 @@ final class PromptBuilder
'- Answer directly and confidently using always correct canonical terminology.' '- Answer directly and confidently using always correct canonical terminology.'
]; ];
$systemBlock = "SYSTEM:\n" . implode("\n", $systemLines); $systemBlock = "SYSTEM:\n" . implode("\n", $systemLines);*/
$activePrompt = $this->systemPromptRepository->findActive();
if (!$activePrompt) {
throw new \RuntimeException('No active system prompt configured.');
}
$systemBlock = "SYSTEM:\n" . $activePrompt->getContent();
// ------------------------------------------------------------ // ------------------------------------------------------------
// 2) CONVERSATION CONTEXT (AUTHORITATIVE) // 2) CONVERSATION CONTEXT (AUTHORITATIVE)

View File

@@ -0,0 +1,35 @@
<?php
// src/Controller/Admin/SystemAgentController.php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Service\Admin\IndexNdjsonInspector;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_SUPER_ADMIN')]
final class SystemAgentController extends AbstractController
{
#[Route('/admin/system/agent', name: 'admin_system_agent', methods: ['GET'])]
public function index(Request $request, IndexNdjsonInspector $inspector): Response
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = max(1, min(200, (int) $request->query->get('limit', 50)));
$paths = $inspector->getPaths();
$meta = $inspector->readMeta();
$ndjsonPage = $inspector->readNdjsonPage($page, $limit);
return $this->render('admin/system/agent_overview.html.twig', [
'paths' => $paths,
'meta' => $meta,
'ndjson' => $ndjsonPage,
'debugCount'=>count($ndjsonPage['items'])
]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\SystemPrompt;
use App\Repository\SystemPromptRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_SUPER_ADMIN')]
final class SystemPromptController extends AbstractController
{
#[Route('/admin/system/prompt', name: 'admin_system_prompt')]
public function index(
Request $request,
SystemPromptRepository $repo,
EntityManagerInterface $em
): Response {
if ($request->isMethod('POST')) {
$content = trim($request->request->get('content', ''));
$comment = trim($request->request->get('comment', ''));
if ($content !== '') {
$active = $repo->findActive();
if ($active) {
$active->deactivate();
}
$new = new SystemPrompt(
version: $repo->getNextVersion(),
content: $content,
comment: $comment ?: null,
active: true
);
$em->persist($new);
$em->flush();
$this->addFlash('success', 'Neue Version gespeichert.');
return $this->redirectToRoute('admin_system_prompt');
}
}
return $this->render('admin/system/prompt.html.twig', [
'active' => $repo->findActive(),
'all' => $repo->findBy([], ['version' => 'DESC']),
]);
}
#[Route('/admin/system/prompt/{id}/activate', name: 'admin_system_prompt_activate', methods: ['POST'])]
public function activate(
SystemPrompt $prompt,
SystemPromptRepository $repo,
EntityManagerInterface $em
): Response {
foreach ($repo->findBy(['active' => true]) as $p) {
$p->deactivate();
}
$prompt->activate();
$em->flush();
$this->addFlash('success', 'Version aktiviert.');
return $this->redirectToRoute('admin_system_prompt');
}
#[Route('/admin/system/prompt/{id}/delete', name: 'admin_system_prompt_delete', methods: ['POST'])]
public function delete(
SystemPrompt $prompt,
EntityManagerInterface $em
): Response {
if ($prompt->isActive()) {
$this->addFlash('danger', 'Aktive Version kann nicht gelöscht werden.');
return $this->redirectToRoute('admin_system_prompt');
}
$em->remove($prompt);
$em->flush();
$this->addFlash('success', 'Version gelöscht.');
return $this->redirectToRoute('admin_system_prompt');
}
}

111
src/Entity/SystemPrompt.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
// src/Entity/SystemPrompt.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'system_prompt')]
class SystemPrompt
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(type: 'integer')]
private int $version;
#[ORM\Column(type: 'text')]
private string $content;
// 📝 Neuer Kommentar
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $comment = null;
#[ORM\Column(type: 'boolean')]
private bool $active = false;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
// Optional für Governance / spätere Audit-Erweiterung
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct(
int $version,
string $content,
?string $comment = null,
bool $active = false
) {
$this->id = Uuid::v4();
$this->version = $version;
$this->content = $content;
$this->comment = $comment;
$this->active = $active;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getVersion(): int
{
return $this->version;
}
public function getContent(): string
{
return $this->content;
}
public function getComment(): ?string
{
return $this->comment;
}
public function isActive(): bool
{
return $this->active;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function activate(): void
{
$this->active = true;
$this->touch();
}
public function deactivate(): void
{
$this->active = false;
$this->touch();
}
public function updateContent(string $content, ?string $comment = null): void
{
$this->content = $content;
$this->comment = $comment;
$this->touch();
}
private function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}

View File

@@ -0,0 +1,38 @@
<?php
// src/Repository/SystemPromptRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\SystemPrompt;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SystemPromptRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SystemPrompt::class);
}
public function findActive(): ?SystemPrompt
{
return $this->createQueryBuilder('p')
->andWhere('p.active = true')
->orderBy('p.version', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function getNextVersion(): int
{
$max = $this->createQueryBuilder('p')
->select('MAX(p.version)')
->getQuery()
->getSingleScalarResult();
return ((int)$max) + 1;
}
}

View File

@@ -0,0 +1,132 @@
<?php
// src/Service/Admin/IndexNdjsonInspector.php
declare(strict_types=1);
namespace App\Service\Admin;
final class IndexNdjsonInspector
{
private string $ndjsonPath;
private string $metaPath;
public function __construct(string $ndJsonPath, string $indexMetaPath)
{
// Passe diese Pfade an deine echten Ablageorte an, falls abweichend:
// z.B. var/rag/index.ndjson oder storage/rag/index.ndjson etc.
$this->ndjsonPath = $ndJsonPath;
$this->metaPath = $indexMetaPath;
}
public function getPaths(): array
{
return [
'ndjson' => $this->ndjsonPath,
'meta' => $this->metaPath,
];
}
public function readMeta(): array
{
if (!is_file($this->metaPath)) {
return [
'error' => 'index_meta.json nicht gefunden',
'path' => $this->metaPath,
];
}
$raw = @file_get_contents($this->metaPath);
if ($raw === false) {
return [
'error' => 'index_meta.json konnte nicht gelesen werden',
'path' => $this->metaPath,
];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return [
'error' => 'index_meta.json ist kein valides JSON-Objekt',
'path' => $this->metaPath,
];
}
return $data;
}
/**
* Liest NDJSON "streaming" und gibt nur einen Ausschnitt zurück.
* - Keine Voll-Loads
* - Maximal limit Zeilen
* - Offset = (page-1)*limit
*/
public function readNdjsonPage(int $page, int $limit): array
{
$page = max(1, $page);
$limit = max(1, min(200, $limit)); // hard cap für Admin-UI
if (!is_file($this->ndjsonPath)) {
return [
'error' => 'index.ndjson nicht gefunden',
'path' => $this->ndjsonPath,
'items' => [],
];
}
$handle = @fopen($this->ndjsonPath, 'rb');
if ($handle === false) {
return [
'error' => 'index.ndjson konnte nicht geöffnet werden',
'path' => $this->ndjsonPath,
'items' => [],
];
}
$offsetLines = ($page - 1) * $limit;
$items = [];
$lineNo = 0;
// Sicherheitslimit: wir lesen max. X Bytes pro Line (um Monster-Zeilen abzufangen)
$maxLineBytes = 1024 * 1024; // 1MB pro Zeile
while (!feof($handle)) {
$line = fgets($handle, $maxLineBytes);
if ($line === false) {
break;
}
$lineNo++;
if ($lineNo <= $offsetLines) {
continue;
}
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
// Ungültige Zeile ignorieren, aber sichtbar machen wäre auch ok.
continue;
}
$items[] = $decoded;
if (count($items) >= $limit) {
break;
}
}
fclose($handle);
return [
'error' => null,
'path' => $this->ndjsonPath,
'items' => $items,
'page' => $page,
'limit' => $limit,
];
}
}

View File

@@ -33,7 +33,17 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_jobs') }}"> <a class="nav-link text-light" href="{{ path('admin_jobs') }}">
Ingest Jobs Indexierung Jobs (Ingest)
</a>
</li>
<li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_system_agent') }}">
Wissen (Chunk-Index)
</a>
</li>
<li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_system_prompt') }}">
System Prompt Settings
</a> </a>
</li> </li>
</ul> </ul>

View File

@@ -0,0 +1,135 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Agent System Overview{% endblock %}
{% block body %}
<a href="{{ path('admin_dashboard') }}"
class="btn btn-sm btn-outline-light mb-3">
← Zurück
</a>
<h1 class="h4 mb-4">Agent System Overview</h1>
{# ============================= #}
{# Index Meta Section #}
{# ============================= #}
<div class="card bg-black text-info border-secondary mb-4"{#>
<div class="card-body">
<h5 class="mb-3">Index Meta (index_meta.json)</h5>
{% if meta.error is defined %}
<div class="alert alert-danger">
<strong>Fehler:</strong><br>
{{ meta.error }}<br>
<small>{{ meta.path }}</small>
</div>
{% else %}
<table class="table table-dark table-sm table-bordered align-middle mb-0">
<tbody>
{% for key, value in meta %}
<tr>
<th style="width:280px;">{{ key }}</th>
<td>
{% if value is iterable %}
<pre class="mb-0 text-info">
{{ value|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
{% else %}
{{ value }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>#}
{# ============================= #}
{# NDJSON Section #}
{# ============================= #}
{% set currentPage = ndjson.page|default(1) %}
{% set currentLimit = ndjson.limit|default(50) %}
<div class="card bg-black text-info border-secondary mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">NdJson Index Übersicht Chunks (index.ndjson)</h5>
<div>
<a href="{{ path('admin_system_agent', {page: (currentPage - 1 < 1 ? 1 : currentPage - 1), limit: currentLimit}) }}"
class="btn btn-sm btn-outline-light">
← Zurück
</a>
<a href="{{ path('admin_system_agent', {page: currentPage + 1, limit: currentLimit}) }}"
class="btn btn-sm btn-outline-light">
Weiter →
</a>
</div>
</div>
{% if ndjson.error %}
<div class="alert alert-danger">
<strong>Fehler:</strong><br>
{{ ndjson.error }}<br>
<small>{{ ndjson.path|default('-') }}</small>
</div>
{% endif %}
<div class="mb-2 text-secondary">
Datei vorhanden: {{ ndjson.path ? 'JA' : 'NEIN' }} |
Geladene Einträge: {{ debugCount|default(0) }} |
Seite {{ currentPage }} • Limit {{ currentLimit }}
</div>
<div class="table-responsive">
<table class="table table-dark table-sm table-bordered align-middle">
<thead>
<tr>
<th style="width:220px;">chunk_id</th>
<th style="width:180px;">document_id</th>
<th>text (gekürzt)</th>
</tr>
</thead>
<tbody>
{% for item in ndjson.items|default([]) %}
<tr>
<td>{{ item.chunk_id ?? '-' }}</td>
<td>{{ item.document_id ?? '-' }}</td>
<td>
{% set text = item.text ?? '' %}
{{ text|slice(0, 240) }}{% if text|length > 240 %}{% endif %}
<details class="mt-2">
<summary class="text-secondary" style="cursor:pointer;">
JSON anzeigen
</summary>
<pre class="bg-dark text-info p-2 border border-secondary rounded mt-2">
{{ item|json_encode(constant('JSON_PRETTY_PRINT')) }}
</pre>
</details>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-secondary">
Keine Einträge gefunden.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends 'admin/base.html.twig' %}
{% block title %}System Prompt{% endblock %}
{% block body %}
<h1 class="h4 mb-4">System Prompt</h1>
{% for message in app.flashes('success') %}
<div class="alert alert-success">{{ message }}</div>
{% endfor %}
{% for message in app.flashes('danger') %}
<div class="alert alert-danger">{{ message }}</div>
{% endfor %}
<div class="card bg-black text-info border-secondary mb-4">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Kommentar (optional)</label>
<input type="text"
name="comment"
class="form-control bg-dark text-info border-secondary"
placeholder="Warum wurde der Prompt geändert?">
</div>
<div class="mb-3">
<label class="form-label">Prompt Inhalt</label>
<textarea name="content"
rows="16"
class="form-control bg-dark text-info border-secondary"
>{{ active ? active.content : '' }}</textarea>
</div>
<button class="btn btn-outline-light">
Neue Version speichern
</button>
</form>
</div>
</div>
<div class="card bg-black text-info border-secondary">
<div class="card-body">
<h5>Versionen</h5>
<table class="table table-dark table-sm table-bordered align-middle">
<thead>
<tr>
<th>Version</th>
<th>Aktiv</th>
<th>Kommentar</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for p in all %}
<tr>
<td>{{ p.version }}</td>
<td>
{% if p.active %}
<span class="badge bg-success">ACTIVE</span>
{% endif %}
</td>
<td>{{ p.comment ?? '-' }}</td>
<td>{{ p.createdAt|date('d.m.Y H:i:s') }}</td>
<td>
{% if not p.active %}
<form method="post"
action="{{ path('admin_system_prompt_activate', {id: p.id}) }}"
style="display:inline-block;">
<button class="btn btn-sm btn-outline-light">
Aktivieren
</button>
</form>
<form method="post"
action="{{ path('admin_system_prompt_delete', {id: p.id}) }}"
style="display:inline-block;"
onsubmit="return confirm('Version wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">
Löschen
</button>
</form>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}