alpha new hybridretriver line

This commit is contained in:
team2
2026-02-26 07:02:07 +01:00
parent c12ae8b45e
commit df97f9314b
9 changed files with 460 additions and 152 deletions

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260225000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds retrieval_max_chunks and retrieval_vector_top_k to model_generation_config (without DB defaults)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_generation_config
ADD retrieval_max_chunks INT NOT NULL,
ADD retrieval_vector_top_k INT NOT NULL
');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE model_generation_config
DROP retrieval_max_chunks,
DROP retrieval_vector_top_k
');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260225000200 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fix schema drift (indexes, FK, longtext nullability)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE knowledge_tag CHANGE description description LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE knowledge_tag RENAME INDEX uniq_knowledge_tag_slug TO UNIQ_86B46255989D9B62');
$this->addSql('ALTER TABLE document DROP FOREIGN KEY FK_D8698A769407EE77');
$this->addSql('ALTER TABLE document ADD CONSTRAINT FK_D8698A769407EE77 FOREIGN KEY (current_version_id) REFERENCES document_version (id)');
$this->addSql('ALTER TABLE tag_rebuild_job CHANGE error_message error_message LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE document_tag RENAME INDEX idx_document_tag_document TO IDX_D0234567C33F7837');
$this->addSql('ALTER TABLE document_tag RENAME INDEX idx_document_tag_tag TO IDX_D0234567BAD26311');
}
public function down(Schema $schema): void
{
// Optional in Drift-Fix-Migration meist nicht notwendig
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\ModelGenerationConfig;
use App\Knowledge\Retrieval\NdjsonHybridRetriever;
use App\Repository\ModelGenerationConfigRepository;
use App\Service\ModelGenerationConfigManager;
use Doctrine\ORM\EntityManagerInterface;
@@ -21,7 +22,10 @@ class ModelGenerationConfigController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_KNOWLEDGE_ADMIN');
$configs = $repository->findBy([], ['active' => 'DESC', 'modelName' => 'ASC', 'version' => 'DESC']);
$configs = $repository->findBy(
[],
['active' => 'DESC', 'modelName' => 'ASC', 'version' => 'DESC']
);
return $this->render('admin/model_config/list.html.twig', [
'configs' => $configs,
@@ -38,10 +42,12 @@ class ModelGenerationConfigController extends AbstractController
if ($request->isMethod('POST')) {
$modelName = $request->request->get('model_name');
$modelName = (string) $request->request->get('model_name');
$version = $repository->findNextVersion($modelName);
$retrievalMaxChunks = (int) $request->request->get('retrieval_max_chunks', 25);
$retrievalVectorTopK = (int) $request->request->get('retrieval_vector_top_k', 25);
$config = new ModelGenerationConfig(
modelName: $modelName,
version: $version,
@@ -51,7 +57,9 @@ class ModelGenerationConfigController extends AbstractController
topP: (float) $request->request->get('top_p'),
repeatPenalty: (float) $request->request->get('repeat_penalty'),
numCtx: (int) $request->request->get('num_ctx'),
active: false
active: false,
retrievalMaxChunks: $retrievalMaxChunks,
retrievalVectorTopK: $retrievalVectorTopK
);
$em->persist($config);
@@ -75,6 +83,32 @@ class ModelGenerationConfigController extends AbstractController
return $this->redirectToRoute('admin_model_config_list');
}
#[Route('/{id}/test-retrieval', name: 'admin_model_config_test_retrieval')]
public function testRetrieval(
ModelGenerationConfig $config,
Request $request,
NdjsonHybridRetriever $retriever
): Response {
$this->denyAccessUnlessGranted('ROLE_KNOWLEDGE_ADMIN');
$results = [];
$prompt = '';
if ($request->isMethod('POST')) {
$prompt = trim((string) $request->request->get('prompt'));
if ($prompt !== '') {
$results = $retriever->retrieveForConfig($prompt, $config);
}
}
return $this->render('admin/model_config/test_retrieval.html.twig', [
'config' => $config,
'results' => $results,
'prompt' => $prompt,
]);
}
#[Route('/{id}/delete', name: 'admin_model_config_delete', methods: ['POST'])]
public function delete(
ModelGenerationConfig $config,
@@ -99,5 +133,4 @@ class ModelGenerationConfigController extends AbstractController
return $this->redirectToRoute('admin_model_config_list');
}
}

View File

@@ -9,9 +9,15 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: \App\Repository\ModelGenerationConfigRepository::class)]
#[ORM\Table(name: 'model_generation_config')]
#[ORM\Index(columns: ['model_name', 'active'], name: 'idx_model_active')]
#[ORM\Index(name: 'idx_model_active', columns: ['model_name', 'active'])]
class ModelGenerationConfig
{
// -----------------------------
// Hard Guardrails
// -----------------------------
private const MAX_RETRIEVAL_CHUNKS = 200;
private const MAX_VECTOR_TOPK = 200;
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
@@ -37,6 +43,16 @@ class ModelGenerationConfig
#[ORM\Column(name: 'num_ctx', type: 'integer')]
private int $numCtx;
// -------------------------------------
// Retrieval-Parameter (NEU)
// -------------------------------------
#[ORM\Column(name: 'retrieval_max_chunks', type: 'integer')]
private int $retrievalMaxChunks;
#[ORM\Column(name: 'retrieval_vector_top_k', type: 'integer')]
private int $retrievalVectorTopK;
#[ORM\Column(type: 'boolean')]
private bool $active;
@@ -55,7 +71,9 @@ class ModelGenerationConfig
float $topP = 0.8,
float $repeatPenalty = 1.05,
int $numCtx = 4096,
bool $active = false
bool $active = false,
int $retrievalMaxChunks = 25,
int $retrievalVectorTopK = 25,
) {
$this->id = Uuid::v4();
$this->modelName = $modelName;
@@ -68,8 +86,15 @@ class ModelGenerationConfig
$this->numCtx = $numCtx;
$this->active = $active;
$this->createdAt = new \DateTimeImmutable();
$this->setRetrievalMaxChunks($retrievalMaxChunks);
$this->setRetrievalVectorTopK($retrievalVectorTopK);
}
// -----------------------------
// Getter
// -----------------------------
public function getId(): Uuid { return $this->id; }
public function getModelName(): string { return $this->modelName; }
public function isStream(): bool { return $this->stream; }
@@ -82,9 +107,35 @@ class ModelGenerationConfig
public function getVersion(): int { return $this->version; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getRetrievalMaxChunks(): int
{
return $this->retrievalMaxChunks;
}
public function getRetrievalVectorTopK(): int
{
return $this->retrievalVectorTopK;
}
// -----------------------------
// Controlled Mutators
// -----------------------------
// Nur vom Manager nutzen
public function setActive(bool $active): void
{
$this->active = $active;
}
public function setRetrievalMaxChunks(int $value): void
{
$value = max(1, min($value, self::MAX_RETRIEVAL_CHUNKS));
$this->retrievalMaxChunks = $value;
}
public function setRetrievalVectorTopK(int $value): void
{
$value = max(1, min($value, self::MAX_VECTOR_TOPK));
$this->retrievalVectorTopK = $value;
}
}

View File

@@ -4,43 +4,65 @@ declare(strict_types=1);
namespace App\Knowledge\Retrieval;
use App\Entity\ModelGenerationConfig;
use App\Knowledge\ChunkManager;
use App\Repository\ModelGenerationConfigRepository;
use App\Tag\TagRoutingService;
use App\Vector\VectorSearchClient;
final class NdjsonHybridRetriever implements RetrieverInterface
{
private const VECTOR_SCORE_THRESHOLD = 0.25;
/**
* Wenn Tag-Routing aktiv ist, erhöhen wir TopK,
* weil wir danach per document_id filtern.
*/
private const VECTOR_TOPK_MULTIPLIER_WHEN_ROUTED = 10;
/**
* Keyword-Scan: Mindest-Trefferanzahl an Terms, damit ein Chunk als Kandidat gilt.
*/
private const KEYWORD_MIN_HITS = 1;
private const HARD_MAX_CHUNKS = 200;
private const HARD_MAX_VECTORK = 200;
public function __construct(
private readonly ChunkManager $chunkManager,
private readonly NdjsonChunkLookup $lookup,
private readonly VectorSearchClient $vectorClient,
private readonly TagRoutingService $tagRouting,
private readonly int $maxChunks = 100,
private readonly int $vectorTopK = 100,
private readonly ModelGenerationConfigRepository $configRepository,
) {}
public function retrieve(string $prompt, int $limit = null): array
/**
* Normalbetrieb ausschließlich aktive Config.
*/
public function retrieve(string $prompt): array
{
$limit = $this->maxChunks;
$config = $this->configRepository->findActiveForModel();
if ($config === null) {
throw new \RuntimeException('No active ModelGenerationConfig found.');
}
return $this->retrieveInternal($prompt, $config);
}
/**
* Admin-Testbetrieb explizite Config.
* Verändert KEINEN globalen Zustand.
*/
public function retrieveForConfig(string $prompt, ModelGenerationConfig $config): array
{
return $this->retrieveInternal($prompt, $config);
}
/**
* Zentrale Retrieval-Logik (keine Duplikation).
*/
private function retrieveInternal(string $prompt, ModelGenerationConfig $config): array
{
$limit = max(1, min($config->getRetrievalMaxChunks(), self::HARD_MAX_CHUNKS));
$vectorTopKBase = max(1, min($config->getRetrievalVectorTopK(), self::HARD_MAX_VECTORK));
// ---------------------------------------------------------
// 0) Tag-Routing FIRST (soft gate)
// 1) Tag-Vector FIRST -> candidateSet (DocIDs)
// ---------------------------------------------------------
$candidateDocIds = $this->tagRouting->route($prompt);
$candidateDocIds = $this->tagRouting->route($prompt); // <= DAS muss intern auf Tag-Vector gehen
$candidateSet = null;
if (is_array($candidateDocIds) && $candidateDocIds !== []) {
@@ -48,31 +70,22 @@ final class NdjsonHybridRetriever implements RetrieverInterface
}
// ---------------------------------------------------------
// 1) Keyword first (simple streaming scan)
// 2) Vector chunks (primary)
// ---------------------------------------------------------
$terms = $this->extractTerms($prompt);
$keywordChunks = $this->keywordSearchStreaming($terms, $limit, $candidateSet);
if (\count($keywordChunks) >= $limit) {
return array_slice($keywordChunks, 0, $limit);
}
// ---------------------------------------------------------
// 2) Vector fallback / enrichment
// - If routed: increase TopK, then filter by document_id
// - Soft fallback: if filtering yields nothing -> global vector once
// ---------------------------------------------------------
$topK = $this->vectorTopK;
$topK = $vectorTopKBase;
if ($candidateSet !== null) {
$topK = max($this->vectorTopK * self::VECTOR_TOPK_MULTIPLIER_WHEN_ROUTED, $this->vectorTopK);
$topK = min($topK, 200); // guardrail
$topK = min(
max($vectorTopKBase * self::VECTOR_TOPK_MULTIPLIER_WHEN_ROUTED, $vectorTopKBase),
self::HARD_MAX_VECTORK
);
}
$hits = $this->vectorClient->search($prompt, $topK);
if ($hits === []) {
return $keywordChunks;
// Tags-only System: kein Vector-Hit -> keine Chunks
return [];
}
$chunkIds = [];
@@ -87,14 +100,15 @@ final class NdjsonHybridRetriever implements RetrieverInterface
}
if ($chunkIds === []) {
return $keywordChunks;
return [];
}
$rows = $this->lookup->findByChunkIds($chunkIds);
// routed filtering by document_id
$finalChunkIds = $chunkIds;
// ---------------------------------------------------------
// 3) Routed filtering (wenn candidateSet vorhanden)
// ---------------------------------------------------------
if ($candidateSet !== null) {
$filtered = [];
@@ -103,18 +117,20 @@ final class NdjsonHybridRetriever implements RetrieverInterface
if (!is_array($row)) {
continue;
}
$docId = $row['document_id'] ?? null;
if (!is_string($docId) || !isset($candidateSet[$docId])) {
continue;
}
$filtered[] = $id;
}
// Soft fallback: if routing filtered everything away, retry global vector once
// Wenn Routing ALLES wegfiltert -> einmal global retry
if ($filtered === []) {
$hits2 = $this->vectorClient->search($prompt, $this->vectorTopK);
$hits2 = $this->vectorClient->search($prompt, $vectorTopKBase);
if ($hits2 === []) {
return $keywordChunks;
return [];
}
$chunkIds2 = [];
@@ -129,7 +145,7 @@ final class NdjsonHybridRetriever implements RetrieverInterface
}
if ($chunkIds2 === []) {
return $keywordChunks;
return [];
}
$rows = $this->lookup->findByChunkIds($chunkIds2);
@@ -139,24 +155,25 @@ final class NdjsonHybridRetriever implements RetrieverInterface
}
}
foreach ($finalChunkIds as $id) {
if (!isset($rows[$id]['text']) || !is_string($rows[$id]['text'])) {
continue;
}
$keywordChunks[] = trim($rows[$id]['text']);
}
// ---------------------------------------------------------
// 3) dedupe + limit
// 4) Collect texts + Dedupe + Limit
// ---------------------------------------------------------
$seen = [];
$out = [];
foreach ($keywordChunks as $chunk) {
foreach ($finalChunkIds as $id) {
$text = $rows[$id]['text'] ?? null;
if (!is_string($text) || $text === '') {
continue;
}
$chunk = trim($text);
$key = mb_strtolower((string)preg_replace('/\s+/u', ' ', $chunk));
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $chunk;
@@ -168,16 +185,6 @@ final class NdjsonHybridRetriever implements RetrieverInterface
return $out;
}
/**
* Streaming Keyword Search über index.ndjson.
* Minimal, aber nützlich:
* - Score = Anzahl gefundener Terms
* - CandidateDocs (Tag-Routing) reduziert Scan massiv
*
* @param string[] $terms
* @param array<string,true>|null $candidateSet
* @return string[]
*/
private function keywordSearchStreaming(array $terms, int $limit, ?array $candidateSet): array
{
if ($terms === []) {
@@ -185,31 +192,28 @@ final class NdjsonHybridRetriever implements RetrieverInterface
}
$maxScore = \count($terms);
// top list: each item = ['score' => int, 'text' => string]
$top = [];
foreach ($this->chunkManager->streamAll() as $row) {
$text = $row['text'] ?? null;
if (!is_string($text) || $text === '') {
continue;
}
if ($candidateSet !== null) {
$docId = $row['document_id'] ?? null;
if (!is_string($docId) || !isset($candidateSet[$docId])) {
continue;
}
}
$haystack = mb_strtolower($text);
$score = 0;
foreach ($terms as $t) {
if ($t === '') {
continue;
}
if (mb_stripos($haystack, $t) !== false) {
if ($t !== '' && mb_stripos($haystack, $t) !== false) {
$score++;
}
}
@@ -223,14 +227,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
'text' => trim($text),
];
// keep only best N (simple sort, N is tiny)
usort($top, static function (array $a, array $b): int {
// higher score first
$cmp = ($b['score'] <=> $a['score']);
if ($cmp !== 0) {
return $cmp;
}
// shorter chunk first (often more precise)
return (mb_strlen($a['text']) <=> mb_strlen($b['text']));
});
@@ -238,25 +239,14 @@ final class NdjsonHybridRetriever implements RetrieverInterface
$top = array_slice($top, 0, $limit);
}
// early exit: perfect matches filled
if (\count($top) === $limit && ($top[0]['score'] ?? 0) >= $maxScore) {
break;
}
}
$out = [];
foreach ($top as $item) {
$out[] = (string)$item['text'];
return array_map(static fn($item) => (string)$item['text'], $top);
}
return $out;
}
/**
* Minimal term extraction (stabiles Verhalten, wenig Magie)
*
* @return string[]
*/
private function extractTerms(string $text): array
{
$text = mb_strtolower((string)preg_replace('/[^\p{L}\p{N}\s]/u', '', $text));
@@ -266,16 +256,15 @@ final class NdjsonHybridRetriever implements RetrieverInterface
static fn(string $w) => mb_strlen($w) > 2
));
// unique, order preserved
$seen = [];
$out = [];
foreach ($parts as $w) {
if (isset($seen[$w])) {
continue;
}
if (!isset($seen[$w])) {
$seen[$w] = true;
$out[] = $w;
}
}
return $out;
}

View File

@@ -1,11 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Knowledge\Retrieval;
/**
* Retrieval ist vollständig konfigurationsgetrieben.
*
* - retrievalMaxChunks stammt ausschließlich aus der aktiven ModelGenerationConfig.
* - retrievalVectorTopK stammt ausschließlich aus der aktiven ModelGenerationConfig.
* - Es existiert kein Runtime-Override.
*
* Ziel:
* Deterministisches, auditierbares Retrieval-Verhalten.
*/
interface RetrieverInterface
{
/**
* Retrieves relevant knowledge chunks for a given prompt.
*
* The number of returned chunks is strictly defined by
* the active ModelGenerationConfig (retrievalMaxChunks).
*
* @return string[] Plain text knowledge chunks
*/
public function retrieve(string $prompt, int $limit = 10): array;
public function retrieve(string $prompt): array;
}

View File

@@ -22,7 +22,10 @@
<div class="row g-4">
<!-- Modell -->
<!-- ============================== -->
<!-- Modell-Basis -->
<!-- ============================== -->
<div class="col-md-6">
<label class="form-label">Modellname</label>
<input type="text"
@@ -31,11 +34,10 @@
placeholder="z. B. qwen3:latest"
required>
<div class="form-text text-secondary">
Exakter Modellname wie im KI-Endpunkt konfiguriert (z. B. Ollama oder API).
Exakter Modellname wie im KI-Endpunkt konfiguriert.
</div>
</div>
<!-- Stream -->
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<input class="form-check-input"
@@ -48,11 +50,22 @@
</label>
</div>
<div class="form-text text-secondary ms-3">
Aktiviert Token-Streaming im Chat (empfohlen für bessere UX).
Token-Streaming im Chat (empfohlen).
</div>
</div>
<!-- Temperature -->
</div>
<hr class="border-secondary my-4">
<!-- ============================== -->
<!-- GENERATION -->
<!-- ============================== -->
<h5 class="text-info mb-3">Generation (LLM Sampling)</h5>
<div class="row g-4">
<div class="col-md-4">
<label class="form-label">Temperature</label>
<input type="number"
@@ -64,29 +77,24 @@
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Steuert die Kreativität der Antworten.
Niedrige Werte (0.20.4) erzeugen stabile, sachliche Ergebnisse empfohlen für RAG-Systeme.
Höhere Werte führen zu freieren, weniger deterministischen Antworten.
Kreativität des Modells. Für RAG 0.20.4 empfohlen.
</div>
</div>
<!-- Top K -->
<div class="col-md-4">
<label class="form-label">Top K</label>
<input type="number"
min="1"
max="200"
name="top_k"
value="40"
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Begrenzt die Anzahl der wahrscheinlichsten Token, aus denen das Modell auswählt.
Niedrigere Werte = konservativer, höhere Werte = flexibler.
2050 ist für Wissenssysteme üblich.
Begrenzt die Auswahl wahrscheinlicher Token.
</div>
</div>
<!-- Top P -->
<div class="col-md-4">
<label class="form-label">Top P</label>
<input type="number"
@@ -98,13 +106,10 @@
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Nucleus Sampling: Das Modell berücksichtigt nur Token,
deren kumulative Wahrscheinlichkeit innerhalb dieses Werts liegt.
0.80.95 bietet eine gute Balance zwischen Stabilität und Natürlichkeit.
Nucleus Sampling Balance zwischen Stabilität und Natürlichkeit.
</div>
</div>
<!-- Repeat Penalty -->
<div class="col-md-6">
<label class="form-label">Repeat Penalty</label>
<input type="number"
@@ -116,12 +121,10 @@
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Bestraft Wortwiederholungen. Werte leicht über 1.0 (z. B. 1.11.15)
verhindern Schleifen und redundante Antworten.
Verhindert Wiederholungen (1.11.15 empfohlen).
</div>
</div>
<!-- Num Ctx -->
<div class="col-md-6">
<label class="form-label">Context Window (num_ctx)</label>
<input type="number"
@@ -132,9 +135,50 @@
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Maximale Kontextlänge in Tokens (Systemprompt + Benutzerfrage + Retrieval-Chunks).
Muss vom Modell unterstützt werden.
Höhere Werte ermöglichen größere Wissenskontexte, erhöhen jedoch Speicher- und Rechenbedarf.
Maximale Kontextlänge (System + Frage + Retrieval).
</div>
</div>
</div>
<hr class="border-secondary my-4">
<!-- ============================== -->
<!-- RETRIEVAL -->
<!-- ============================== -->
<h5 class="text-warning mb-3">Retrieval (Wissensabruf)</h5>
<div class="row g-4">
<div class="col-md-6">
<label class="form-label">Max Chunks</label>
<input type="number"
min="1"
max="200"
name="retrieval_max_chunks"
value="25"
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Maximale Anzahl an Wissens-Chunks,
die dem Modell übergeben werden.
2040 ist für die meisten Systeme optimal.
</div>
</div>
<div class="col-md-6">
<label class="form-label">Vector Top K</label>
<input type="number"
min="1"
max="200"
name="retrieval_vector_top_k"
value="25"
class="form-control bg-dark text-light border-secondary"
required>
<div class="form-text text-secondary">
Anzahl der Vektor-Treffer vor Filterung.
Höhere Werte erhöhen Recall, können aber Rauschen verstärken.
</div>
</div>

View File

@@ -24,11 +24,8 @@
<th>Modell</th>
<th>Version</th>
<th>Stream</th>
<th>Temp</th>
<th>Top K</th>
<th>Top P</th>
<th>Repeat</th>
<th>Ctx</th>
<th>Sampling</th>
<th class="text-warning">Retrieval</th>
<th>Status</th>
<th class="text-end">Aktionen</th>
</tr>
@@ -52,11 +49,28 @@
{% endif %}
</td>
<td>{{ config.temperature }}</td>
<td>{{ config.topK }}</td>
<td>{{ config.topP }}</td>
<td>{{ config.repeatPenalty }}</td>
<td>{{ config.numCtx }}</td>
{# ========================= #}
{# Sampling Group #}
{# ========================= #}
<td>
<div class="small">
<div><strong>T:</strong> {{ config.temperature }}</div>
<div><strong>K:</strong> {{ config.topK }}</div>
<div><strong>P:</strong> {{ config.topP }}</div>
<div><strong>R:</strong> {{ config.repeatPenalty }}</div>
<div><strong>Ctx:</strong> {{ config.numCtx }}</div>
</div>
</td>
{# ========================= #}
{# Retrieval Group #}
{# ========================= #}
<td class="text-warning">
<div class="small">
<div><strong>Chunks:</strong> {{ config.retrievalMaxChunks }}</div>
<div><strong>VectorK:</strong> {{ config.retrievalVectorTopK }}</div>
</div>
</td>
<td>
{% if config.active %}
@@ -69,10 +83,13 @@
</td>
<td class="text-end">
<a href="{{ path('admin_model_config_test_retrieval', {id: config.id}) }}"
class="btn btn-sm btn-outline-warning me-1">
Test Retrieval
</a>
{% if not config.active and is_granted('ROLE_SUPER_ADMIN') %}
{# Aktivieren via POST + CSRF #}
<form method="post"
action="{{ path('admin_model_config_activate', {id: config.id}) }}"
class="d-inline"
@@ -82,12 +99,11 @@
name="_token"
value="{{ csrf_token('activate_model_config_' ~ config.id) }}">
<button class="btn btn-sm btn-outline-success me-2">
<button class="btn btn-sm btn-outline-success me-1">
Aktivieren
</button>
</form>
{# Löschen via POST + CSRF #}
<form method="post"
action="{{ path('admin_model_config_delete', {id: config.id}) }}"
class="d-inline"
@@ -105,13 +121,12 @@
{% else %}
<span class="text-secondary small">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="10" class="text-center text-secondary py-4">
<td colspan="7" class="text-center text-secondary py-4">
Keine Konfiguration vorhanden.
</td>
</tr>
@@ -132,13 +147,11 @@
<div class="card bg-black border-info">
<div class="card-body p-0">
<iframe
src="/index.html?admin_test=1"
class="w-100 border-0"
style="height:75vh;"
></iframe>
</div>
</div>
@@ -146,4 +159,5 @@
Der Agent läuft im isolierten Admin-Test-Modus.
Keine Persistenz. Keine produktive Session.
</div>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Retrieval-Test{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
Retrieval-Test {{ config.modelName }} (v{{ config.version }})
</h1>
<a href="{{ path('admin_model_config_list') }}"
class="btn btn-sm btn-outline-secondary">
Zurück
</a>
</div>
{# ====================================================== #}
{# Konfigurationsübersicht #}
{# ====================================================== #}
<div class="card bg-black border-secondary text-light mb-4">
<div class="card-body small">
<div class="row">
<div class="col-md-6">
<strong>Max Chunks:</strong>
{{ config.retrievalMaxChunks }}
</div>
<div class="col-md-6">
<strong>Vector Top K:</strong>
{{ config.retrievalVectorTopK }}
</div>
</div>
</div>
</div>
{# ====================================================== #}
{# Testformular #}
{# ====================================================== #}
<div class="card bg-black border-secondary text-light mb-4">
<div class="card-body">
<form method="post">
<div class="mb-3">
<label class="form-label">Test-Prompt</label>
<textarea name="prompt"
rows="3"
class="form-control bg-dark text-light border-secondary"
required>{{ prompt }}</textarea>
</div>
<button type="submit"
class="btn btn-outline-warning">
Retrieval testen
</button>
</form>
</div>
</div>
{# ====================================================== #}
{# Ergebnisse #}
{# ====================================================== #}
{% if results is not empty %}
<div class="card bg-black border-secondary text-light">
<div class="card-body">
<h5 class="text-warning mb-3">
Gefundene Chunks ({{ results|length }})
</h5>
<div style="max-height: 500px; overflow-y: auto;">
{% for chunk in results %}
<div class="border border-secondary p-3 mb-3 small">
{{ chunk }}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endblock %}