optimize ui
add new ki endpoint params
This commit is contained in:
103
src/Controller/Admin/ModelGenerationConfigController.php
Normal file
103
src/Controller/Admin/ModelGenerationConfigController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use App\Repository\ModelGenerationConfigRepository;
|
||||
use App\Service\ModelGenerationConfigManager;
|
||||
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;
|
||||
|
||||
#[Route('/admin/model-config')]
|
||||
class ModelGenerationConfigController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'admin_model_config_list')]
|
||||
public function list(ModelGenerationConfigRepository $repository): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_KNOWLEDGE_ADMIN');
|
||||
|
||||
$configs = $repository->findBy([], ['modelName' => 'ASC', 'version' => 'DESC']);
|
||||
|
||||
return $this->render('admin/model_config/list.html.twig', [
|
||||
'configs' => $configs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/create', name: 'admin_model_config_create')]
|
||||
public function create(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
ModelGenerationConfigRepository $repository
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
|
||||
$modelName = $request->request->get('model_name');
|
||||
|
||||
$version = $repository->findNextVersion($modelName);
|
||||
|
||||
$config = new ModelGenerationConfig(
|
||||
modelName: $modelName,
|
||||
version: $version,
|
||||
stream: (bool)$request->request->get('stream'),
|
||||
temperature: (float)$request->request->get('temperature'),
|
||||
topK: (int)$request->request->get('top_k'),
|
||||
topP: (float)$request->request->get('top_p'),
|
||||
repeatPenalty: (float)$request->request->get('repeat_penalty'),
|
||||
numCtx: (int)$request->request->get('num_ctx'),
|
||||
active: false
|
||||
);
|
||||
|
||||
$em->persist($config);
|
||||
$em->flush();
|
||||
|
||||
return $this->redirectToRoute('admin_model_config_list');
|
||||
}
|
||||
|
||||
return $this->render('admin/model_config/create.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/{id}/activate', name: 'admin_model_config_activate')]
|
||||
public function activate(
|
||||
ModelGenerationConfig $config,
|
||||
ModelGenerationConfigManager $manager
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
$manager->activate($config);
|
||||
|
||||
return $this->redirectToRoute('admin_model_config_list');
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'admin_model_config_delete', methods: ['POST'])]
|
||||
public function delete(
|
||||
ModelGenerationConfig $config,
|
||||
Request $request,
|
||||
EntityManagerInterface $em
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
|
||||
if ($config->isActive()) {
|
||||
$this->addFlash('danger', 'Aktive Konfiguration kann nicht gelöscht werden.');
|
||||
return $this->redirectToRoute('admin_model_config_list');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('delete_model_config_'.$config->getId(), $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$em->remove($config);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Konfiguration gelöscht.');
|
||||
|
||||
return $this->redirectToRoute('admin_model_config_list');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,4 +32,5 @@ final class SystemAgentController extends AbstractController
|
||||
'debugCount'=>count($ndjsonPage['items'])
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
90
src/Entity/ModelGenerationConfig.php
Normal file
90
src/Entity/ModelGenerationConfig.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
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')]
|
||||
class ModelGenerationConfig
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private Uuid $id;
|
||||
|
||||
#[ORM\Column(name: 'model_name', type: 'string', length: 120)]
|
||||
private string $modelName;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $stream;
|
||||
|
||||
#[ORM\Column(type: 'float')]
|
||||
private float $temperature;
|
||||
|
||||
#[ORM\Column(name: 'top_k', type: 'integer')]
|
||||
private int $topK;
|
||||
|
||||
#[ORM\Column(name: 'top_p', type: 'float')]
|
||||
private float $topP;
|
||||
|
||||
#[ORM\Column(name: 'repeat_penalty', type: 'float')]
|
||||
private float $repeatPenalty;
|
||||
|
||||
#[ORM\Column(name: 'num_ctx', type: 'integer')]
|
||||
private int $numCtx;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $version;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
string $modelName,
|
||||
int $version,
|
||||
bool $stream = false,
|
||||
float $temperature = 0.1,
|
||||
int $topK = 20,
|
||||
float $topP = 0.8,
|
||||
float $repeatPenalty = 1.05,
|
||||
int $numCtx = 4096,
|
||||
bool $active = false
|
||||
) {
|
||||
$this->id = Uuid::v4();
|
||||
$this->modelName = $modelName;
|
||||
$this->version = $version;
|
||||
$this->stream = $stream;
|
||||
$this->temperature = $temperature;
|
||||
$this->topK = $topK;
|
||||
$this->topP = $topP;
|
||||
$this->repeatPenalty = $repeatPenalty;
|
||||
$this->numCtx = $numCtx;
|
||||
$this->active = $active;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): Uuid { return $this->id; }
|
||||
public function getModelName(): string { return $this->modelName; }
|
||||
public function isStream(): bool { return $this->stream; }
|
||||
public function getTemperature(): float { return $this->temperature; }
|
||||
public function getTopK(): int { return $this->topK; }
|
||||
public function getTopP(): float { return $this->topP; }
|
||||
public function getRepeatPenalty(): float { return $this->repeatPenalty; }
|
||||
public function getNumCtx(): int { return $this->numCtx; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getVersion(): int { return $this->version; }
|
||||
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||
|
||||
// Nur vom Manager nutzen
|
||||
public function setActive(bool $active): void
|
||||
{
|
||||
$this->active = $active;
|
||||
}
|
||||
}
|
||||
@@ -4,69 +4,54 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use App\Service\ModelGenerationConfigProvider;
|
||||
use Generator;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* OllamaClient
|
||||
*
|
||||
* Production-ready streaming client for Ollama-compatible LLM backends.
|
||||
*
|
||||
* Key properties:
|
||||
* - True live streaming (tokens are yielded while the request is running)
|
||||
* - PHP-safe (no yield inside cURL callbacks)
|
||||
* - Works for both HTTP streaming and CLI usage
|
||||
* - Deterministic and resource-safe
|
||||
*
|
||||
* Implementation strategy:
|
||||
* - Use curl_multi_* to keep control of the execution loop
|
||||
* - Accumulate partial chunks into a rolling buffer
|
||||
* - Extract JSON lines incrementally
|
||||
* - Yield tokens immediately when they arrive
|
||||
*/
|
||||
final class OllamaClient
|
||||
{
|
||||
private string $apiUrl;
|
||||
private string $model;
|
||||
private int $timeoutSeconds;
|
||||
private ?ModelGenerationConfig $cachedConfig = null;
|
||||
|
||||
public function __construct(
|
||||
string $apiUrl,
|
||||
string $model,
|
||||
int $timeoutSeconds,
|
||||
)
|
||||
{
|
||||
$this->apiUrl = $apiUrl;
|
||||
$this->model = $model;
|
||||
$this->timeoutSeconds = $timeoutSeconds;
|
||||
}
|
||||
private string $apiUrl,
|
||||
private string $model,
|
||||
private int $timeoutSeconds,
|
||||
private ModelGenerationConfigProvider $configProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Streams tokens from the LLM backend in real time.
|
||||
*
|
||||
* @param string $prompt Fully constructed prompt
|
||||
*
|
||||
* @return Generator<string>
|
||||
* @throws JsonException
|
||||
* Public Streaming API
|
||||
*/
|
||||
public function stream(string $prompt): Generator
|
||||
{
|
||||
$json = [];
|
||||
$config = $this->getConfig();
|
||||
|
||||
$payload = json_encode([
|
||||
'model' => $this->model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => true,
|
||||
'options' => [
|
||||
"temperature" => 0.9,
|
||||
"top_k" => 35,
|
||||
"top_p" => 0.9,
|
||||
"repeat_penalty" => 1.1,
|
||||
"num_ctx" => 8192
|
||||
]
|
||||
], JSON_THROW_ON_ERROR);
|
||||
if ($config->isStream()) {
|
||||
yield from $this->streamInternal($prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Blocking generate → Generator-kompatibel ausgeben
|
||||
yield $this->generateInternal($prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Blocking API
|
||||
*/
|
||||
public function generate(string $prompt): string
|
||||
{
|
||||
return $this->generateInternal($prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal streaming transport
|
||||
*/
|
||||
private function streamInternal(string $prompt): Generator
|
||||
{
|
||||
$payload = $this->buildPayload($prompt, true);
|
||||
|
||||
$buffer = '';
|
||||
$done = false;
|
||||
@@ -82,7 +67,7 @@ final class OllamaClient
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_WRITEFUNCTION => function ($curl, string $data) use (&$buffer, &$done): int {
|
||||
CURLOPT_WRITEFUNCTION => function ($curl, string $data) use (&$buffer): int {
|
||||
$buffer .= $data;
|
||||
return strlen($data);
|
||||
},
|
||||
@@ -98,12 +83,10 @@ final class OllamaClient
|
||||
|
||||
try {
|
||||
do {
|
||||
// Execute the multi handle
|
||||
do {
|
||||
$status = curl_multi_exec($mh, $running);
|
||||
} while ($status === CURLM_CALL_MULTI_PERFORM);
|
||||
|
||||
// Read incoming data from the buffer
|
||||
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||
$line = trim(substr($buffer, 0, $pos));
|
||||
$buffer = substr($buffer, $pos + 1);
|
||||
@@ -127,37 +110,94 @@ final class OllamaClient
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for network activity
|
||||
if ($running) {
|
||||
curl_multi_select($mh, 0.2);
|
||||
}
|
||||
|
||||
} while ($running && !$done);
|
||||
|
||||
// Flush remaining buffer (edge case)
|
||||
if (!$done && trim($buffer) !== '') {
|
||||
try {
|
||||
$json = json_decode(trim($buffer), true, flags: JSON_THROW_ON_ERROR);
|
||||
if (isset($json['response'])) {
|
||||
yield $json['response'];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($json['response'])) {
|
||||
yield $json;
|
||||
return;
|
||||
}
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
throw new RuntimeException('LLM connection error: ' . $error);
|
||||
throw new RuntimeException('LLM connection error: ' . curl_error($ch));
|
||||
}
|
||||
|
||||
} finally {
|
||||
curl_multi_remove_handle($mh, $ch);
|
||||
curl_multi_close($mh);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal blocking transport
|
||||
*/
|
||||
private function generateInternal(string $prompt): string
|
||||
{
|
||||
$payload = $this->buildPayload($prompt, false);
|
||||
|
||||
$ch = curl_init($this->apiUrl);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Failed to initialize cURL');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new RuntimeException('LLM error: ' . curl_error($ch));
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
$json = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
return $json['response'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Central Payload Builder (DRY)
|
||||
*/
|
||||
private function buildPayload(string $prompt, bool $stream): string
|
||||
{
|
||||
return json_encode([
|
||||
'model' => $this->model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => $stream,
|
||||
'options' => $this->buildOptions()
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Central Options Builder (DRY)
|
||||
*/
|
||||
private function buildOptions(): array
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
return [
|
||||
'temperature' => $config->getTemperature(),
|
||||
'top_k' => $config->getTopK(),
|
||||
'top_p' => $config->getTopP(),
|
||||
'repeat_penalty' => $config->getRepeatPenalty(),
|
||||
'num_ctx' => $config->getNumCtx(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Config caching per request
|
||||
*/
|
||||
private function getConfig(): ModelGenerationConfig
|
||||
{
|
||||
if ($this->cachedConfig === null) {
|
||||
$this->cachedConfig = $this->configProvider->getActiveForModel($this->model);
|
||||
}
|
||||
|
||||
return $this->cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
41
src/Repository/ModelGenerationConfigRepository.php
Normal file
41
src/Repository/ModelGenerationConfigRepository.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
final class ModelGenerationConfigRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ModelGenerationConfig::class);
|
||||
}
|
||||
|
||||
public function findActiveForModel(string $modelName): ?ModelGenerationConfig
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->andWhere('c.modelName = :model')
|
||||
->andWhere('c.active = true')
|
||||
->setParameter('model', $modelName)
|
||||
->orderBy('c.version', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findNextVersion(string $modelName): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->select('MAX(c.version)')
|
||||
->andWhere('c.modelName = :model')
|
||||
->setParameter('model', $modelName);
|
||||
|
||||
$max = $qb->getQuery()->getSingleScalarResult();
|
||||
|
||||
return $max ? ((int)$max + 1) : 1;
|
||||
}
|
||||
}
|
||||
72
src/Service/ModelGenerationConfigManager.php
Normal file
72
src/Service/ModelGenerationConfigManager.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use App\Repository\ModelGenerationConfigRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class ModelGenerationConfigManager
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private ModelGenerationConfigRepository $repository
|
||||
) {}
|
||||
|
||||
public function activate(ModelGenerationConfig $config): void
|
||||
{
|
||||
$this->validate($config);
|
||||
|
||||
$this->em->wrapInTransaction(function () use ($config) {
|
||||
|
||||
// Alle aktiven für dieses Modell deaktivieren
|
||||
$activeConfigs = $this->repository->findBy([
|
||||
'modelName' => $config->getModelName(),
|
||||
'active' => true
|
||||
]);
|
||||
|
||||
foreach ($activeConfigs as $active) {
|
||||
$active->setActive(false);
|
||||
$this->em->persist($active);
|
||||
}
|
||||
|
||||
// Diese aktivieren
|
||||
$config->setActive(true);
|
||||
$this->em->persist($config);
|
||||
|
||||
$this->em->flush();
|
||||
});
|
||||
}
|
||||
|
||||
private function validate(ModelGenerationConfig $config): void
|
||||
{
|
||||
// Guardrails
|
||||
|
||||
if ($config->getTemperature() < 0.0 || $config->getTemperature() > 2.0) {
|
||||
throw new \InvalidArgumentException('Temperature must be between 0.0 and 2.0');
|
||||
}
|
||||
|
||||
if ($config->getTopP() <= 0.0 || $config->getTopP() > 1.0) {
|
||||
throw new \InvalidArgumentException('Top P must be between 0.0 and 1.0');
|
||||
}
|
||||
|
||||
if ($config->getTopK() <= 0) {
|
||||
throw new \InvalidArgumentException('Top K must be > 0');
|
||||
}
|
||||
|
||||
if ($config->getRepeatPenalty() < 0.0 || $config->getRepeatPenalty() > 5.0) {
|
||||
throw new \InvalidArgumentException('Repeat Penalty out of allowed range');
|
||||
}
|
||||
|
||||
if ($config->getNumCtx() < 512 || $config->getNumCtx() > 32768) {
|
||||
throw new \InvalidArgumentException('Num Ctx outside safe range');
|
||||
}
|
||||
|
||||
// Enterprise RAG Warnbereich (optional, nur Logging)
|
||||
if ($config->getTemperature() > 0.7) {
|
||||
// hier könntest du optional Logging einbauen
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/Service/ModelGenerationConfigProvider.php
Normal file
42
src/Service/ModelGenerationConfigProvider.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use App\Repository\ModelGenerationConfigRepository;
|
||||
|
||||
final class ModelGenerationConfigProvider
|
||||
{
|
||||
public function __construct(
|
||||
private ModelGenerationConfigRepository $repository
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getActiveForModel(string $modelName): ModelGenerationConfig
|
||||
{
|
||||
$config = $this->repository->findActiveForModel($modelName);
|
||||
|
||||
if ($config !== null) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Safe Enterprise Default Fallback
|
||||
// ------------------------------
|
||||
return new ModelGenerationConfig(
|
||||
modelName: $modelName,
|
||||
version: 0,
|
||||
stream: false,
|
||||
temperature: 0.1,
|
||||
topK: 20,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 1.05,
|
||||
numCtx: 4096,
|
||||
active: false
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user