p96 follow up
This commit is contained in:
@@ -73,6 +73,15 @@ security:
|
||||
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin/logout$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin/users, roles: ROLE_SUPER_ADMIN }
|
||||
- { path: ^/admin/system-logs, roles: ROLE_SUPER_ADMIN }
|
||||
- { path: ^/admin/vector, roles: ROLE_SUPER_ADMIN }
|
||||
- { path: '^/admin/system/(?:prompt|agent)', roles: ROLE_SUPER_ADMIN }
|
||||
- { path: ^/admin/jobs/global-reindex, roles: ROLE_SUPER_ADMIN }
|
||||
- { path: '^/admin/ingest-profiles/(?:create|activate|remove)', roles: ROLE_SUPER_ADMIN }
|
||||
- { path: ^/admin/ingest-profiles, roles: ROLE_KNOWLEDGE_ADMIN }
|
||||
- { path: ^/admin/model-config, roles: ROLE_KNOWLEDGE_ADMIN }
|
||||
- { path: ^/admin/documents, roles: ROLE_EDITOR }
|
||||
- { path: ^/admin/tags, roles: ROLE_EDITOR }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN_AREA }
|
||||
|
||||
- { path: ^/chat/login$, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -245,7 +245,7 @@ parameters:
|
||||
hide_when_material_query_matches_current: true
|
||||
shop_results:
|
||||
- label: Preis anzeigen
|
||||
prompt: Zeige mir die Preise zu {shop_price_query}.
|
||||
prompt: 'Zeige mir die Preise zu folgendem Produkt bzw. den Produkten: {shop_price_query}.'
|
||||
action_type: price_details
|
||||
hide_when_answer_matches_any:
|
||||
- '/\b(?:preis(?:angabe|information|e)?|preise?)\b.{0,100}\b(?:nicht|kein(?:e|en)?|ohne)\b.{0,100}\b(?:angegeben|vorhanden|enthalten|ausgewiesen|gefunden|verfügbar|verfuegbar)\b/iu'
|
||||
|
||||
@@ -1208,6 +1208,14 @@ parameters:
|
||||
- kostet
|
||||
- shopsuche
|
||||
- shop-suche
|
||||
- information
|
||||
- informationen
|
||||
- info
|
||||
- infos
|
||||
- details
|
||||
- detail
|
||||
- daten
|
||||
- angaben
|
||||
context_fallback_filter_terms:
|
||||
- preis
|
||||
- preise
|
||||
@@ -1217,6 +1225,14 @@ parameters:
|
||||
- grenzwert
|
||||
- grenzwerte
|
||||
- grenzwerten
|
||||
- information
|
||||
- informationen
|
||||
- info
|
||||
- infos
|
||||
- details
|
||||
- detail
|
||||
- daten
|
||||
- angaben
|
||||
- welche
|
||||
- gut
|
||||
- geeignet
|
||||
@@ -1286,6 +1302,7 @@ parameters:
|
||||
- wuerde
|
||||
- will
|
||||
- brauche
|
||||
- explizit
|
||||
- benötigen
|
||||
- benoetigen
|
||||
- benötige
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# RetrieX Patch p95 - Admin Role Matrix Hardening
|
||||
|
||||
## Ziel
|
||||
|
||||
Dieser Patch gleicht die im Admin sichtbare Rollenlogik mit der serverseitigen Zugriffskontrolle ab. Rollen sollen nicht nur in der Userverwaltung auswählbar sein, sondern an den relevanten Routen, Controllern und UI-Aktionen konsistent wirksam werden.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Nach p93/p94 waren Userverwaltung, Aktiv/Inaktiv-Login-Blocker und Access-Denied-Seiten funktionsfähig. Bei der Rollenprüfung fielen aber mehrere Inkonsistenzen auf:
|
||||
|
||||
- `ROLE_EDITOR` existierte in der Hierarchie, wurde aber kaum als fachliche Serverberechtigung genutzt.
|
||||
- Einige Aktionen waren im UI nur für Super-Admins sichtbar, serverseitig aber nur durch den allgemeinen `/admin`-Zugriff geschützt.
|
||||
- Indexierungsprofile konnten serverseitig nicht streng genug zwischen Ansicht und kritischen Aktionen unterscheiden.
|
||||
- `admin_ingest_profile_remove` erlaubte noch `GET` als Methode.
|
||||
- Sidebar und Dashboard zeigten Links bzw. Aktionen, die für die jeweilige Rolle später nur in 403 endeten.
|
||||
|
||||
## Umgesetzte Zielmatrix
|
||||
|
||||
| Bereich | Effektive Rolle |
|
||||
| --- | --- |
|
||||
| Chat, Ask/SSE, History, Frontend-Messages | `ROLE_CHAT_USER` |
|
||||
| Admin-Dashboard, Guides, allgemeine Adminbasis, Ingest-Job-Ansicht | `ROLE_ADMIN_AREA` |
|
||||
| Dokumente, Dokumentversionen, Tags, Tag-Zuweisungen, Dokument-Ingest | `ROLE_EDITOR` |
|
||||
| Modell-/Retrieval-Konfiguration lesen/testen, Ingest-Profile ansehen | `ROLE_KNOWLEDGE_ADMIN` |
|
||||
| Userverwaltung, Logs, System Prompt, System Agent, Reset/Delete, Global Reindex, Profil-/Modell-Umschaltungen | `ROLE_SUPER_ADMIN` |
|
||||
|
||||
Die bestehende Hierarchie bleibt erhalten:
|
||||
|
||||
```yaml
|
||||
ROLE_SUPER_ADMIN: [ROLE_KNOWLEDGE_ADMIN, ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
|
||||
ROLE_KNOWLEDGE_ADMIN: [ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
|
||||
ROLE_EDITOR: [ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
|
||||
ROLE_ADMIN_AREA: [ROLE_USER]
|
||||
ROLE_CHAT_USER: [ROLE_USER]
|
||||
```
|
||||
|
||||
## Geänderte Dateien
|
||||
|
||||
### Controller / Security
|
||||
|
||||
- `config/packages/security.yaml`
|
||||
- ergänzt konkrete `access_control`-Regeln vor dem generischen `/admin`-Fallback
|
||||
- schützt Dokument-/Tag-Bereiche mit `ROLE_EDITOR`
|
||||
- schützt Modell-/Ingest-Konfigbereiche mit `ROLE_KNOWLEDGE_ADMIN`
|
||||
- schützt kritische Systembereiche mit `ROLE_SUPER_ADMIN`
|
||||
|
||||
- `src/Security/ApplicationRoles.php`
|
||||
- Dokumentation der zentralen Rollenquelle erweitert
|
||||
|
||||
- `src/Controller/Admin/DocumentController.php`
|
||||
- Dokumentliste/-details und alle Dokumentpflegeaktionen nun `ROLE_EDITOR`
|
||||
- Reset/Delete bleiben `ROLE_SUPER_ADMIN`
|
||||
- Rollenstrings auf `ApplicationRoles`-Konstanten umgestellt
|
||||
|
||||
- `src/Controller/Admin/DocumentTagController.php`
|
||||
- Tagbearbeitung auf Dokumentebene nun `ROLE_EDITOR`
|
||||
|
||||
- `src/Controller/Admin/TagController.php`
|
||||
- Tagliste, Taganlage, Löschung und Zuweisung nun `ROLE_EDITOR`
|
||||
|
||||
- `src/Controller/Admin/IngestProfileController.php`
|
||||
- Profilansicht nun `ROLE_KNOWLEDGE_ADMIN`
|
||||
- Profilanlage, Aktivierung und Löschung nun `ROLE_SUPER_ADMIN`
|
||||
- `remove` nur noch per `POST`
|
||||
- Aktivieren/Löschen prüfen jetzt serverseitig CSRF-Token
|
||||
|
||||
- `src/Controller/Admin/ModelGenerationConfigController.php`
|
||||
- `src/Controller/Admin/IngestJobController.php`
|
||||
- `src/Controller/Admin/AdminVectorLogController.php`
|
||||
- `src/Controller/Admin/AdminSystemLogController.php`
|
||||
- `src/Controller/Admin/SystemAgentController.php`
|
||||
- `src/Controller/Admin/SystemPromptController.php`
|
||||
- `src/Controller/Admin/UserController.php`
|
||||
- Rollenstrings auf zentrale `ApplicationRoles`-Konstanten umgestellt
|
||||
|
||||
### Templates
|
||||
|
||||
- `templates/admin/base.html.twig`
|
||||
- Sidebar blendet Rollenbereiche passend aus:
|
||||
- User: Super-Admin
|
||||
- Dokumente/Tags: Editor
|
||||
- System Prompt/Agent/Logs: Super-Admin
|
||||
- KI-/LLM-Setup und Ingest-Profile: Knowledge-Admin
|
||||
|
||||
- `templates/admin/dashboard/index.html.twig`
|
||||
- Global-Reindex-Button nur noch für Super-Admins sichtbar
|
||||
- Nicht-Super-Admins sehen einen Hinweis statt eines Buttons, der in 403 läuft
|
||||
|
||||
- `templates/admin/document/index.html.twig`
|
||||
- `templates/admin/document/show.html.twig`
|
||||
- `templates/admin/document/new_version.html.twig`
|
||||
- Dokumentpflege- und Versionsaktionen auf `ROLE_EDITOR` ausgerichtet
|
||||
- Dokumentlöschung bleibt `ROLE_SUPER_ADMIN`
|
||||
|
||||
- `templates/admin/ingest_profile/list.html.twig`
|
||||
- Profilanlage nur noch für Super-Admins sichtbar
|
||||
- Aktivieren/Löschen bleiben Super-Admin-Aktionen
|
||||
|
||||
- `templates/admin/tag/index.html.twig`
|
||||
- Taganlage, Zuweisung und Löschung nur für Editor sichtbar
|
||||
- Nicht-Editor sehen keine mutierenden Aktionen
|
||||
|
||||
## Nicht geändert
|
||||
|
||||
- RAG-/Retrieval-Logik
|
||||
- Scoring
|
||||
- Shop-Matching
|
||||
- PromptBuilder
|
||||
- AgentRunner
|
||||
- Chat-Antwortlogik
|
||||
- User-CRUD-Fachlogik aus p93
|
||||
- Error-Page-/Access-Denied-Grundlogik aus p94
|
||||
|
||||
## Lokale Checks
|
||||
|
||||
Ausgeführt im ZIP-Stand ohne `vendor/`:
|
||||
|
||||
```bash
|
||||
find src/Controller/Admin src/Security src/Repository src/Service/Admin -type f -name '*.php' -print0 | xargs -0 -n1 php -l
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
yaml.safe_load(Path('config/packages/security.yaml').read_text())
|
||||
print('[OK] yaml parse')
|
||||
PY
|
||||
```
|
||||
|
||||
Zusätzlich wurde eine einfache Twig-Balance-Prüfung auf `{% if %}`, `{% for %}` und `{% block %}` für die geänderten Templates durchgeführt.
|
||||
|
||||
## Empfohlene Checks in der Zielumgebung
|
||||
|
||||
```bash
|
||||
php bin/console cache:clear
|
||||
php bin/console lint:yaml config/packages/security.yaml
|
||||
php bin/console lint:twig templates/admin
|
||||
php bin/console debug:router | grep admin_
|
||||
php bin/console mto:agent:config:validate
|
||||
php bin/console mto:agent:regression:test
|
||||
php bin/console mto:agent:config:audit-source --details
|
||||
```
|
||||
|
||||
## Manuelle Rollentests
|
||||
|
||||
### `ROLE_ADMIN_AREA`
|
||||
|
||||
Soll können:
|
||||
|
||||
- `/admin/dashboard`
|
||||
- `/admin/guides`
|
||||
- `/admin/jobs`
|
||||
|
||||
Soll verweigert bekommen:
|
||||
|
||||
- `/admin/documents`
|
||||
- `/admin/tags`
|
||||
- `/admin/model-config`
|
||||
- `/admin/ingest-profiles`
|
||||
- `/admin/users`
|
||||
|
||||
### `ROLE_EDITOR`
|
||||
|
||||
Soll können:
|
||||
|
||||
- Dokumente ansehen/anlegen
|
||||
- Dokumentversionen hochladen
|
||||
- Versionen aktivieren
|
||||
- Ingest für Dokumentversionen starten
|
||||
- Tags anlegen/löschen/zuweisen
|
||||
|
||||
Soll verweigert bekommen:
|
||||
|
||||
- Userverwaltung
|
||||
- System Prompt
|
||||
- System Agent
|
||||
- Systemlogs / Vectorlog
|
||||
- Global Reindex
|
||||
- Dokumentlöschung / kompletter Reset
|
||||
- Ingest-Profil aktivieren/löschen
|
||||
|
||||
### `ROLE_KNOWLEDGE_ADMIN`
|
||||
|
||||
Soll können:
|
||||
|
||||
- alles aus `ROLE_EDITOR`
|
||||
- Modellkonfiguration ansehen/testen
|
||||
- Ingest-Profile ansehen
|
||||
|
||||
Soll verweigert bekommen:
|
||||
|
||||
- Userverwaltung
|
||||
- System Prompt/Agent
|
||||
- Logs
|
||||
- Profil-/Modell-Aktivierung und Löschung
|
||||
- Global Reindex
|
||||
- Reset/Delete
|
||||
|
||||
### `ROLE_SUPER_ADMIN`
|
||||
|
||||
Soll alles können.
|
||||
@@ -0,0 +1,197 @@
|
||||
# RetrieX Patch 96 - Follow-up Product Identity & Weak History Guard
|
||||
|
||||
## Ziel
|
||||
|
||||
Dieser Patch haertet zwei beobachtete Folgefrage-Fehlerklassen, ohne Retrieval, Scoring, Shop-Ranking oder Shop-Matching fachlich umzubauen:
|
||||
|
||||
1. **Preis-Folgeaktionen nach mehreren sichtbaren Produkten** duerfen nicht mehr auf den ersten generischen Modellanker zurueckfallen.
|
||||
- Fehlerfall: Antwort nennt `Testomat 2000 CLT` und `Testomat LAB CL`; der Button `Preis anzeigen` erzeugte nur `Testomat 2000`.
|
||||
- Neu: Wenn mehrere in der Antwort sichtbare Produkte erkannt werden, wird die Preis-Folgeaktion aus den konkreten Produktidentitaeten mit Produktnummern gebaut.
|
||||
|
||||
2. **Referenzielle Shop-Nachfragen mit schwacher Query** wie `suche im Shop nach der Information` duerfen den Produktfokus aus der History nicht verlieren.
|
||||
- Fehlerfall: Nach `Testomat 2000 THCL` wurde die Shopquery zu `information` statt zum Verlaufanker `testomat 2000 thcl`.
|
||||
- Neu: Schwache referenzielle Shopqueries, die nur aus Meta-/Info-/Noise-Tokens bestehen, werden mit dem letzten Produktmodellanker aus der History ersetzt.
|
||||
|
||||
## Geaenderte Dateien
|
||||
|
||||
- `src/Agent/AgentRunner.php`
|
||||
- `config/retriex/chat-messages.yaml`
|
||||
- `config/retriex/genre.yaml`
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
### 1. Preis-Folgeaktionen behalten mehrere konkrete Produktidentitaeten
|
||||
|
||||
`buildFollowUpActionPriceQuery()` faellt bei mehreren angezeigten Produkten nicht mehr auf `answer_anchor` zurueck. Stattdessen wird eine begrenzte Produktliste aus den sichtbaren Shopprodukten gebaut, inklusive Produktnummern.
|
||||
|
||||
Beispiel erwarteter Button-Prompt:
|
||||
|
||||
```text
|
||||
Zeige mir die Preise zu folgendem Produkt bzw. den Produkten: Testomat 2000® CLT 100137; Testomat® LAB CL 116106.
|
||||
```
|
||||
|
||||
Der Prompt enthaelt bewusst `Produkt/Produkten` und `Preise`, damit die bestehende Product-List-Follow-up-Logik fuer mehrere Produkte greifen kann und einzelne Produktanker separat suchen kann.
|
||||
|
||||
### 2. Schwache referenzielle Shopqueries nutzen History-Modellanker
|
||||
|
||||
Neue Guard-Funktion:
|
||||
|
||||
```php
|
||||
guardWeakReferentialShopQueryWithHistoryModelAnchor()
|
||||
```
|
||||
|
||||
Sie greift nur, wenn:
|
||||
|
||||
- die aktuelle Frage referenziell genug ist, um History fuer Shopqueries zu nutzen,
|
||||
- die Shopquery keinen eigenen Produktmodellanker enthaelt,
|
||||
- der Prompt selbst keinen neuen Produktmodellanker enthaelt,
|
||||
- es kein Product-List-Follow-up ist,
|
||||
- es kein Indikator-/Zubehoer-/Reagenz-Follow-up ist,
|
||||
- die Query nur aus schwachen Meta-/Info-/Noise-Tokens besteht,
|
||||
- in der History ein Produktmodellanker gefunden wird.
|
||||
|
||||
Die schwachen Info-Woerter werden in `genre.yaml` gepflegt:
|
||||
|
||||
- `information`
|
||||
- `informationen`
|
||||
- `info`
|
||||
- `infos`
|
||||
- `details`
|
||||
- `detail`
|
||||
- `daten`
|
||||
- `angaben`
|
||||
|
||||
## Bewusst nicht geaendert
|
||||
|
||||
- Keine neue fachliche Produktliste im PHP-Core.
|
||||
- Keine neue Testomat-/THCL-/CLT-/LAB-CL-Sonderlogik im Core.
|
||||
- Keine Aenderung an Retrieval, Scoring, Ranking, Shopware-Suche oder PromptBuilder-Faktenregeln.
|
||||
- Bestehende Indikator-/Zubehoer-Follow-up-Guards bleiben priorisiert.
|
||||
|
||||
## Lokale Checks
|
||||
|
||||
Im Patch-Arbeitsverzeichnis ausgefuehrt:
|
||||
|
||||
```bash
|
||||
php -l src/Agent/AgentRunner.php
|
||||
python3 - <<'PY'
|
||||
import yaml, pathlib
|
||||
for path in pathlib.Path('config/retriex').glob('*.yaml'):
|
||||
with path.open(encoding='utf-8') as f:
|
||||
yaml.safe_load(f)
|
||||
print('all retriex yaml OK')
|
||||
PY
|
||||
```
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- `AgentRunner.php`: Syntax OK
|
||||
- alle `config/retriex/*.yaml`: YAML OK
|
||||
|
||||
Nicht lokal ausfuehrbar, weil `vendor/` im ZIP nicht enthalten ist:
|
||||
|
||||
```bash
|
||||
bin/console mto:agent:config:validate
|
||||
bin/console mto:agent:regression:test
|
||||
bin/console mto:agent:config:audit-source --details
|
||||
bin/console mto:agent:config:audit-patterns --details
|
||||
```
|
||||
|
||||
## Testprompts nach Einspielen
|
||||
|
||||
### Fehlerfall 1: Preis-Folgeaktion nach zwei Chlor-Geraeten
|
||||
|
||||
```text
|
||||
mit welchem testomat kann ich freies chlor messen
|
||||
```
|
||||
|
||||
Erwartung in der Antwort:
|
||||
|
||||
- Hauptprodukte bleiben fokussiert auf die passenden Chlor-Geraete, insbesondere:
|
||||
- `Testomat 2000® CLT` / `100137`
|
||||
- `Testomat® LAB CL` / `116106`
|
||||
- Folgeaktion `Preis anzeigen` darf nicht mehr nur `Testomat 2000` materialisieren.
|
||||
|
||||
Danach den angezeigten Preis-Button klicken oder sinngleich testen:
|
||||
|
||||
```text
|
||||
Zeige mir die Preise zu folgendem Produkt bzw. den Produkten: Testomat 2000® CLT 100137; Testomat® LAB CL 116106.
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Shopquery/Split-Lookups fokussieren die konkreten Produktidentitaeten.
|
||||
- Es duerfen nicht breit alle `Testomat 2000` Varianten wie Fe, Antox, Br2, ClO2 oder Wartungskoffer als Hauptantwort erscheinen.
|
||||
|
||||
### Fehlerfall 2: schwache Info-Shopnachfrage verliert THCL-Anker
|
||||
|
||||
```text
|
||||
welche grenzwerte kann der testomat 2000 thcl messen
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```text
|
||||
suche im shop nach der information
|
||||
```
|
||||
|
||||
Optional auch Tippfehler-Variante:
|
||||
|
||||
```text
|
||||
such eim shop nach der information
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Die finale Shopquery darf nicht `information` sein.
|
||||
- Erwarteter Verlaufanker: `testomat 2000 thcl`.
|
||||
- Shopantwort bleibt beim Geraet/Modellfokus und driftet nicht zu fremden Testomat-808-SiO2-Zubehoerteilen, Horiba-Testern oder allgemeinen Treffern ab.
|
||||
|
||||
### Gruener Gegenflow: Brauwasser bleibt stabil
|
||||
|
||||
```text
|
||||
ich suche ein wasseranalyse messgerät für eine Brauerei, um das brauwasser messen zu können
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```text
|
||||
begründe deine auswahl
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```text
|
||||
was kostet das gerät denn
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- Auswahl bleibt auf `Testomat 2000® CAL` fokussiert, sofern die Begruendung CAL als Hauptempfehlung gesetzt hat.
|
||||
- Preis-Follow-up bleibt `testomat 2000 cal gerät` bzw. entsprechend CAL-fokussiert.
|
||||
- Keine Regression hin zu breiten `testomat 2000` Varianten.
|
||||
|
||||
### Bestehende Praezisionsregression: Indikator 300 bleibt stabil
|
||||
|
||||
```text
|
||||
Was ist der niedrigste Grenzwert für die Wasserhärte, welcher mit einem Testomaten überwacht werden kann?
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```text
|
||||
mit welchem indikator wird der wert gemessen
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```text
|
||||
was kostet der indikator
|
||||
```
|
||||
|
||||
Erwartung:
|
||||
|
||||
- `0,02 °dH` / `Testomat 808`
|
||||
- `Indikatortyp 300`
|
||||
- Shopquery weiterhin fokussiert, z. B. `testomat 808 300 indikator`
|
||||
- Nur die zwei exakten Indikator-300-Produkte, keine `300 S`-Varianten und keine anderen Codes.
|
||||
@@ -336,6 +336,26 @@ final readonly class AgentRunner
|
||||
$optimizedShopQuery = '';
|
||||
}
|
||||
|
||||
$weakReferentialHistoryModelShopSearchQuery = $this->guardWeakReferentialShopQueryWithHistoryModelAnchor(
|
||||
prompt: $originalPrompt,
|
||||
shopSearchQuery: $shopSearchQuery,
|
||||
commerceHistoryContext: $commerceHistoryContext
|
||||
);
|
||||
|
||||
if ($weakReferentialHistoryModelShopSearchQuery !== $shopSearchQuery) {
|
||||
$this->agentLogger->info('Replaced weak referential shop query with history model anchor', [
|
||||
'userId' => $userId,
|
||||
'prompt' => $prompt,
|
||||
'routingPrompt' => $routingPrompt,
|
||||
'optimizedShopQuery' => $optimizedShopQuery,
|
||||
'shopSearchQuery' => $shopSearchQuery,
|
||||
'weakReferentialHistoryModelShopSearchQuery' => $weakReferentialHistoryModelShopSearchQuery,
|
||||
]);
|
||||
|
||||
$shopSearchQuery = $weakReferentialHistoryModelShopSearchQuery;
|
||||
$optimizedShopQuery = '';
|
||||
}
|
||||
|
||||
$productListAnchoredShopSearchQuery = $this->guardReferentialProductListShopQueryWithHistoryAnchors(
|
||||
prompt: $originalPrompt,
|
||||
shopSearchQuery: $shopSearchQuery,
|
||||
@@ -4148,6 +4168,84 @@ final readonly class AgentRunner
|
||||
: $modelAnchor;
|
||||
}
|
||||
|
||||
private function guardWeakReferentialShopQueryWithHistoryModelAnchor(
|
||||
string $prompt,
|
||||
string $shopSearchQuery,
|
||||
string $commerceHistoryContext
|
||||
): string {
|
||||
$shopSearchQuery = trim($shopSearchQuery);
|
||||
|
||||
if (
|
||||
$shopSearchQuery === ''
|
||||
|| trim($commerceHistoryContext) === ''
|
||||
|| $this->referenceAnchorExtractor->extractFirstProductModelAnchor($prompt) !== ''
|
||||
|| $this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery) !== ''
|
||||
|| $this->isReferentialProductListShopFollowUpPrompt($prompt)
|
||||
) {
|
||||
return $shopSearchQuery;
|
||||
}
|
||||
|
||||
if (!$this->shouldUseCommerceHistoryForShopQuery($prompt)) {
|
||||
return $shopSearchQuery;
|
||||
}
|
||||
|
||||
// Accessory and consumable follow-ups have their own detail-anchor guard.
|
||||
if ($this->containsConfiguredShopQueryAnchorTrigger(trim($prompt . ' ' . $shopSearchQuery))) {
|
||||
return $shopSearchQuery;
|
||||
}
|
||||
|
||||
if (!$this->isWeakReferentialHistoryModelShopQuery($shopSearchQuery)) {
|
||||
return $shopSearchQuery;
|
||||
}
|
||||
|
||||
$modelAnchor = $this->normalizeShopQueryAnchor(
|
||||
$this->extractLatestHistoryProductModelAnchor($commerceHistoryContext)
|
||||
);
|
||||
|
||||
if ($modelAnchor === '') {
|
||||
return $shopSearchQuery;
|
||||
}
|
||||
|
||||
return $this->queryAlreadyContainsAllAnchorTokens($shopSearchQuery, $modelAnchor)
|
||||
? $shopSearchQuery
|
||||
: $modelAnchor;
|
||||
}
|
||||
|
||||
private function isWeakReferentialHistoryModelShopQuery(string $shopSearchQuery): bool
|
||||
{
|
||||
if ($this->referenceAnchorExtractor->extractFirstProductModelAnchor($shopSearchQuery) !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = $this->tokenizeShopQueryCandidate($shopSearchQuery);
|
||||
if ($tokens === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$weakTokens = $this->buildShopQueryTokenSet($this->mergeUniqueStrings(
|
||||
$this->mergeUniqueStrings(
|
||||
$this->getShopQueryMetaGuardTerms(),
|
||||
$this->getShopQueryContextFallbackFilterTerms()
|
||||
),
|
||||
$this->mergeUniqueStrings(
|
||||
$this->agentRunnerConfig->getShopQueryProductListFollowUpNoiseTerms(),
|
||||
$this->agentRunnerConfig->getShopQueryStopwordCleanupTerms()
|
||||
)
|
||||
));
|
||||
|
||||
if ($weakTokens === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($weakTokens[$token])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isMainDeviceReferentialShopQueryPrompt(string $prompt): bool
|
||||
{
|
||||
$tokens = $this->tokenizeShopQueryCandidate($prompt);
|
||||
@@ -6864,15 +6962,27 @@ final readonly class AgentRunner
|
||||
return $this->buildFollowUpActionProductQuery($numberedProducts[0]);
|
||||
}
|
||||
|
||||
if (count($numberedProducts) > 1) {
|
||||
return $this->buildFollowUpActionProductListQuery($numberedProducts);
|
||||
}
|
||||
|
||||
$focusedProducts = $this->filterFollowUpActionPrimaryDisplayedProducts($displayedProducts);
|
||||
if (count($focusedProducts) === 1) {
|
||||
return $this->buildFollowUpActionProductQuery($focusedProducts[0]);
|
||||
}
|
||||
|
||||
if (count($focusedProducts) > 1) {
|
||||
return $this->buildFollowUpActionProductListQuery($focusedProducts);
|
||||
}
|
||||
|
||||
if (count($displayedProducts) === 1) {
|
||||
return $this->buildFollowUpActionProductQuery($displayedProducts[0]);
|
||||
}
|
||||
|
||||
if (count($displayedProducts) > 1) {
|
||||
return $this->buildFollowUpActionProductListQuery($displayedProducts);
|
||||
}
|
||||
|
||||
$answerAnchor = $this->buildFollowUpActionAnswerAnchor($answerText);
|
||||
if ($answerAnchor !== '') {
|
||||
return $answerAnchor;
|
||||
@@ -6941,6 +7051,37 @@ final readonly class AgentRunner
|
||||
return $this->normalizeOneLine(implode(' ', array_filter($parts, static fn(string $part): bool => trim($part) !== '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShopProductResult[] $products
|
||||
*/
|
||||
private function buildFollowUpActionProductListQuery(array $products): string
|
||||
{
|
||||
$queries = [];
|
||||
$seen = [];
|
||||
$maxProducts = max(1, $this->agentRunnerConfig->getShopQueryProductListFollowUpMaxAnchors());
|
||||
|
||||
foreach ($products as $product) {
|
||||
if (!$product instanceof ShopProductResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = $this->buildFollowUpActionProductQuery($product);
|
||||
$key = mb_strtolower($query, 'UTF-8');
|
||||
if ($query === '' || isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$queries[] = $query;
|
||||
|
||||
if (count($queries) >= $maxProducts) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeOneLine(implode('; ', $queries));
|
||||
}
|
||||
|
||||
private function buildFollowUpActionAnswerAnchor(string $answerText): string
|
||||
{
|
||||
$anchors = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -21,7 +22,7 @@ final class AdminSystemLogController extends AbstractController
|
||||
#[Route('', name: 'admin_system_logs_index')]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
$files = [];
|
||||
|
||||
@@ -47,7 +48,7 @@ final class AdminSystemLogController extends AbstractController
|
||||
)]
|
||||
public function view(string $date): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if ($date === 'current') {
|
||||
$safeFilename = 'system.log';
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -14,7 +15,7 @@ final class AdminVectorLogController extends AbstractController
|
||||
#[Route('/log', name: 'admin_vector_log')]
|
||||
public function view(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
// ⚠️ Pfad ggf. anpassen falls bei dir anders konfiguriert
|
||||
$logFile = \dirname(__DIR__, 3) . '/var/log/vector_service.log';
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Entity\Document;
|
||||
use App\Entity\DocumentVersion;
|
||||
use App\Entity\IngestJob;
|
||||
use App\Entity\User;
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Service\DocumentService;
|
||||
use App\Service\FormatText;
|
||||
use App\Service\IngestJobService;
|
||||
@@ -33,6 +34,8 @@ final class DocumentController extends AbstractController
|
||||
#[Route('', name: 'admin_documents', methods: ['GET'])]
|
||||
public function index(EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
$documents = $em->getRepository(Document::class)
|
||||
->createQueryBuilder('d')
|
||||
->leftJoin('d.versions', 'v')
|
||||
@@ -56,6 +59,8 @@ final class DocumentController extends AbstractController
|
||||
)]
|
||||
public function show(string $id, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
return $this->render('admin/document/show.html.twig', [
|
||||
'document' => $this->findDocument($id, $em),
|
||||
]);
|
||||
@@ -70,6 +75,8 @@ final class DocumentController extends AbstractController
|
||||
ParameterBagInterface $params,
|
||||
EntityManagerInterface $em,
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
if (!$request->isMethod('POST')) {
|
||||
return $this->render('admin/document/new.html.twig');
|
||||
}
|
||||
@@ -150,6 +157,8 @@ final class DocumentController extends AbstractController
|
||||
ParameterBagInterface $params,
|
||||
FormatText $formatText,
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
$document = $this->findDocument($id, $em);
|
||||
|
||||
if (!$request->isMethod('POST')) {
|
||||
@@ -202,6 +211,8 @@ final class DocumentController extends AbstractController
|
||||
DocumentService $documentService,
|
||||
IngestJobService $jobService,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
if (!$this->isCsrfTokenValid('activate_version_' . $versionId, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
@@ -260,6 +271,8 @@ final class DocumentController extends AbstractController
|
||||
EntityManagerInterface $em,
|
||||
IngestJobService $jobService,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
if (!$this->isCsrfTokenValid('ingest_version_' . $versionId, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
@@ -329,7 +342,7 @@ final class DocumentController extends AbstractController
|
||||
ParameterBagInterface $params,
|
||||
Connection $connection,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('system_reset', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
@@ -400,7 +413,7 @@ SQL;
|
||||
IngestJobService $jobService,
|
||||
LockService $lockService,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('delete_document_' . $id, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\TagRebuildJob;
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Service\Admin\DocumentTagAdminService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -19,6 +20,8 @@ final class DocumentTagController extends AbstractController
|
||||
#[Route('/{id}/tags', name: 'admin_document_tags_edit', methods: ['GET'])]
|
||||
public function edit(string $id, DocumentTagAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
$id = trim($id);
|
||||
|
||||
try {
|
||||
@@ -38,6 +41,8 @@ final class DocumentTagController extends AbstractController
|
||||
#[Route('/{id}/tags/save', name: 'admin_document_tags_save', methods: ['POST'])]
|
||||
public function save(string $id, Request $request, DocumentTagAdminService $svc): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
$id = trim($id);
|
||||
|
||||
if (!$this->isCsrfTokenValid('admin_document_tags_save_' . $id, (string) $request->request->get('_token'))) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Entity\IngestJob;
|
||||
use App\Service\IngestJobService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -50,7 +51,7 @@ final class IngestJobController extends AbstractController
|
||||
)]
|
||||
public function status(string $id, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN_AREA');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_ADMIN_AREA);
|
||||
|
||||
$job = $this->findJob($id, $em);
|
||||
|
||||
@@ -71,7 +72,7 @@ final class IngestJobController extends AbstractController
|
||||
IngestJobService $jobService,
|
||||
EntityManagerInterface $em,
|
||||
): RedirectResponse {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('global_reindex', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\IngestProfile;
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Service\Admin\IngestProfileAdminService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -17,6 +18,8 @@ final class IngestProfileController extends AbstractController
|
||||
#[Route('/', name: 'admin_ingest_profile_list')]
|
||||
public function list(IngestProfileAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_KNOWLEDGE_ADMIN);
|
||||
|
||||
$data = $svc->listData();
|
||||
|
||||
return $this->render('admin/ingest_profile/list.html.twig', $data);
|
||||
@@ -25,6 +28,8 @@ final class IngestProfileController extends AbstractController
|
||||
#[Route('/create', name: 'admin_ingest_profile_create', methods: ['GET', 'POST'])]
|
||||
public function create(Request $request, IngestProfileAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
try {
|
||||
$svc->create([
|
||||
@@ -49,8 +54,15 @@ final class IngestProfileController extends AbstractController
|
||||
#[Route('/activate/{id}', name: 'admin_ingest_profile_activate', methods: ['POST'])]
|
||||
public function activate(
|
||||
IngestProfile $profile,
|
||||
IngestProfileAdminService $svc
|
||||
IngestProfileAdminService $svc,
|
||||
Request $request
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('activate_ingest_profile_' . $profile->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->activate($profile);
|
||||
$this->addFlash('success', 'Ingest-Profil wurde aktiviert.');
|
||||
@@ -61,9 +73,15 @@ final class IngestProfileController extends AbstractController
|
||||
return $this->redirectToRoute('admin_ingest_profile_list');
|
||||
}
|
||||
|
||||
#[Route('/remove/{id}', name: 'admin_ingest_profile_remove', methods: ['POST', 'GET'])]
|
||||
public function remove(string $id, IngestProfileAdminService $svc): Response
|
||||
#[Route('/remove/{id}', name: 'admin_ingest_profile_remove', methods: ['POST'])]
|
||||
public function remove(string $id, IngestProfileAdminService $svc, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('delete_ingest_profile_' . $id, (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
try {
|
||||
$svc->remove($id);
|
||||
$this->addFlash('success', 'Ingest-Profil wurde entfernt.');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Entity\ModelGenerationConfig;
|
||||
use App\Service\Admin\ModelGenerationConfigAdminService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -17,7 +18,7 @@ final class ModelGenerationConfigController extends AbstractController
|
||||
#[Route('/', name: 'admin_model_config_list')]
|
||||
public function list(ModelGenerationConfigAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_KNOWLEDGE_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_KNOWLEDGE_ADMIN);
|
||||
|
||||
return $this->render('admin/model_config/list.html.twig', [
|
||||
'configs' => $svc->list(),
|
||||
@@ -29,7 +30,7 @@ final class ModelGenerationConfigController extends AbstractController
|
||||
Request $request,
|
||||
ModelGenerationConfigAdminService $svc
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
try {
|
||||
@@ -50,7 +51,7 @@ final class ModelGenerationConfigController extends AbstractController
|
||||
ModelGenerationConfig $config,
|
||||
ModelGenerationConfigAdminService $svc
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
$svc->activate($config);
|
||||
|
||||
@@ -63,7 +64,7 @@ final class ModelGenerationConfigController extends AbstractController
|
||||
Request $request,
|
||||
ModelGenerationConfigAdminService $svc
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_KNOWLEDGE_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_KNOWLEDGE_ADMIN);
|
||||
|
||||
$prompt = '';
|
||||
$results = [];
|
||||
@@ -86,7 +87,7 @@ final class ModelGenerationConfigController extends AbstractController
|
||||
Request $request,
|
||||
ModelGenerationConfigAdminService $svc
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid(
|
||||
'delete_model_config_'.$config->getId(),
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Service\Admin\IndexNdjsonInspector;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_SUPER_ADMIN')]
|
||||
#[IsGranted(ApplicationRoles::ROLE_SUPER_ADMIN)]
|
||||
final class SystemAgentController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/system/agent', name: 'admin_system_agent', methods: ['GET'])]
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Entity\SystemPrompt;
|
||||
use App\Service\Admin\SystemPromptAdminService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_SUPER_ADMIN')]
|
||||
#[IsGranted(ApplicationRoles::ROLE_SUPER_ADMIN)]
|
||||
final class SystemPromptController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/system/prompt', name: 'admin_system_prompt', methods: ['GET', 'POST'])]
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\TagRebuildJob;
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Service\Admin\TagAdminService;
|
||||
use App\Tag\TagTypes;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -19,6 +20,8 @@ final class TagController extends AbstractController
|
||||
#[Route('', name: 'admin_tags_index', methods: ['GET'])]
|
||||
public function index(TagAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
return $this->render('admin/tag/index.html.twig', [
|
||||
...$svc->getIndexData(),
|
||||
...$this->buildJobStatusViewData(),
|
||||
@@ -28,6 +31,8 @@ final class TagController extends AbstractController
|
||||
#[Route('/create', name: 'admin_tags_create', methods: ['POST'])]
|
||||
public function create(Request $request, TagAdminService $svc): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
if (!$this->isCsrfTokenValid('admin_tag_create', (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
@@ -53,6 +58,8 @@ final class TagController extends AbstractController
|
||||
#[Route('/{id}/delete', name: 'admin_tags_delete', methods: ['POST'])]
|
||||
public function delete(string $id, Request $request, TagAdminService $svc): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
if (!$this->isCsrfTokenValid('admin_tag_delete_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
@@ -72,6 +79,8 @@ final class TagController extends AbstractController
|
||||
#[Route('/{id}/assign', name: 'admin_tags_assign', methods: ['GET', 'POST'])]
|
||||
public function assign(string $id, Request $request, TagAdminService $svc): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_EDITOR);
|
||||
|
||||
$id = trim($id);
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Security\ApplicationRoles;
|
||||
use App\Entity\User;
|
||||
use App\Service\Admin\UserAdminService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -19,7 +20,7 @@ final class UserController extends AbstractController
|
||||
#[Route('', name: 'admin_users_index', methods: ['GET'])]
|
||||
public function index(Request $request, UserAdminService $users): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
$status = (string) $request->query->get('status', 'all');
|
||||
@@ -44,7 +45,7 @@ final class UserController extends AbstractController
|
||||
#[Route('/create', name: 'admin_users_create', methods: ['GET', 'POST'])]
|
||||
public function create(Request $request, UserAdminService $users): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$request->isMethod('POST')) {
|
||||
return $this->render('admin/user/create.html.twig', [
|
||||
@@ -80,7 +81,7 @@ final class UserController extends AbstractController
|
||||
#[Route('/{id}/edit', name: 'admin_users_edit', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['GET', 'POST'])]
|
||||
public function edit(string $id, Request $request, UserAdminService $users): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
try {
|
||||
$user = $users->requireUser($id);
|
||||
@@ -127,7 +128,7 @@ final class UserController extends AbstractController
|
||||
#[Route('/{id}/toggle-active', name: 'admin_users_toggle_active', requirements: ['id' => '[0-9a-fA-F\-]{36}'], methods: ['POST'])]
|
||||
public function toggleActive(string $id, Request $request, UserAdminService $users): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
|
||||
$this->denyAccessUnlessGranted(ApplicationRoles::ROLE_SUPER_ADMIN);
|
||||
|
||||
if (!$this->isCsrfTokenValid('admin_user_toggle_active_' . $id, (string) $request->request->get('_token'))) {
|
||||
$this->addFlash('danger', 'Ungültiges CSRF-Token.');
|
||||
|
||||
@@ -17,6 +17,11 @@ final class ApplicationRoles
|
||||
public const ROLE_USER = 'ROLE_USER';
|
||||
|
||||
/**
|
||||
* Roles that can be assigned from the Admin UI or console.
|
||||
*
|
||||
* Effective access is backed by config/packages/security.yaml and by
|
||||
* controller-level guards for action-specific permissions.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function assignableChoices(): array
|
||||
|
||||
@@ -77,23 +77,27 @@
|
||||
RAG Dokumente & Wissen
|
||||
</div>
|
||||
|
||||
<a class="nav-link text-light {% if route starts with 'admin_document' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_documents') }}">
|
||||
<i class="bi bi-card-list"></i> Dokumente
|
||||
</a>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_document' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_documents') }}">
|
||||
<i class="bi bi-card-list"></i> Dokumente
|
||||
</a>
|
||||
|
||||
{# ------------------------- #}
|
||||
{# Tags (Document Routing) #}
|
||||
{# ------------------------- #}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_tags' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_tags_index') }}">
|
||||
<i class="bi bi-tag-fill"></i> Tags
|
||||
</a>
|
||||
{# ------------------------- #}
|
||||
{# Tags (Document Routing) #}
|
||||
{# ------------------------- #}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_tags' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_tags_index') }}">
|
||||
<i class="bi bi-tag-fill"></i> Tags
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="nav-link text-light {% if route starts with 'admin_system_agent' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_agent') }}">
|
||||
<i class="bi bi-robot"></i> Wissensbasis (Chunk-Index)
|
||||
</a>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_system_agent' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_agent') }}">
|
||||
<i class="bi bi-robot"></i> Wissensbasis (Chunk-Index)
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<hr class="border-secondary">
|
||||
|
||||
@@ -101,15 +105,19 @@
|
||||
RAG System-Profile
|
||||
</div>
|
||||
|
||||
<a class="nav-link text-light {% if route starts with 'admin_system_prompt' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_prompt') }}">
|
||||
<i class="bi bi-chat-right-dots-fill"></i> System Prompt
|
||||
</a>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_system_prompt' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_prompt') }}">
|
||||
<i class="bi bi-chat-right-dots-fill"></i> System Prompt
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="nav-link text-light {% if route starts with 'admin_ingest_profile' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_ingest_profile_list') }}">
|
||||
<i class="bi bi-search"></i> Indexierungsprofile (Ingest)
|
||||
</a>
|
||||
{% if is_granted('ROLE_KNOWLEDGE_ADMIN') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_ingest_profile' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_ingest_profile_list') }}">
|
||||
<i class="bi bi-search"></i> Indexierungsprofile (Ingest)
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<hr class="border-secondary">
|
||||
|
||||
@@ -117,14 +125,16 @@
|
||||
KI-Endpunkte
|
||||
</div>
|
||||
|
||||
<a class="nav-link text-light {% if route starts with 'admin_model_config' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_model_config_list') }}">
|
||||
<i class="bi bi-rocket-takeoff-fill"></i> KI-/LLM-Setup
|
||||
</a>
|
||||
<a class="nav-link text-light {% if route starts with 'admin_model_config' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_model_config_list') }}#agentLiveTest">
|
||||
<i class="bi bi-rocket-takeoff-fill"></i> KI-Agent Live-Test
|
||||
</a>
|
||||
{% if is_granted('ROLE_KNOWLEDGE_ADMIN') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_model_config' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_model_config_list') }}">
|
||||
<i class="bi bi-rocket-takeoff-fill"></i> KI-/LLM-Setup
|
||||
</a>
|
||||
<a class="nav-link text-light {% if route starts with 'admin_model_config' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_model_config_list') }}#agentLiveTest">
|
||||
<i class="bi bi-rocket-takeoff-fill"></i> KI-Agent Live-Test
|
||||
</a>
|
||||
{% endif %}
|
||||
<hr class="border-secondary">
|
||||
<div class="text-info text-uppercase small mb-2">
|
||||
System-Guiide
|
||||
@@ -142,14 +152,16 @@
|
||||
href="{{ path('admin_jobs') }}">
|
||||
<i class="bi bi-terminal"></i> Indexierungs-Log (Ingest Jobs)
|
||||
</a>
|
||||
<a class="nav-link text-light {% if route starts with 'admin_job' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_vector_log') }}">
|
||||
<i class="bi bi-terminal"></i> Vector-Log Python
|
||||
</a>
|
||||
<a class="nav-link text-light {% if route starts with 'admin_job' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_logs_index') }}">
|
||||
<i class="bi bi-terminal"></i> System-Logs
|
||||
</a>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<a class="nav-link text-light {% if route starts with 'admin_vector_log' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_vector_log') }}">
|
||||
<i class="bi bi-terminal"></i> Vector-Log Python
|
||||
</a>
|
||||
<a class="nav-link text-light {% if route starts with 'admin_system_logs' %}active fw-bold{% endif %}"
|
||||
href="{{ path('admin_system_logs_index') }}">
|
||||
<i class="bi bi-terminal"></i> System-Logs
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -226,19 +226,25 @@
|
||||
physischen Retrieval-Artefakte wieder gerade.
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
action="{{ path('admin_global_reindex') }}"
|
||||
onsubmit="return confirm('Global Reindex starten? Dies kann mehrere Minuten dauern.');">
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<form method="post"
|
||||
action="{{ path('admin_global_reindex') }}"
|
||||
onsubmit="return confirm('Global Reindex starten? Dies kann mehrere Minuten dauern.');">
|
||||
|
||||
<input type="hidden"
|
||||
name="_token"
|
||||
value="{{ csrf_token('global_reindex') }}">
|
||||
<input type="hidden"
|
||||
name="_token"
|
||||
value="{{ csrf_token('global_reindex') }}">
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Global Reindex starten
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Global Reindex starten
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-dark border border-secondary text-light small mb-0">
|
||||
Global Reindex ist Super-Admins vorbehalten.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if anyHealthIssue %}
|
||||
<div class="alert alert-dark border border-warning text-light small mt-3 mb-0">
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ path('admin_document_new') }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Neues Dokument
|
||||
</a>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a href="{{ path('admin_document_new') }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Neues Dokument
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
@@ -189,10 +191,12 @@
|
||||
|
||||
<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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a class="btn btn-sm btn-outline-info"
|
||||
href="{{ path('admin_document_tags_edit', {id: document.id}) }}">
|
||||
Tags
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a class="btn btn-sm btn-outline-light"
|
||||
href="{{ path('admin_document_show', {id: document.id}) }}">
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-outline-info">
|
||||
Version hochladen
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a href="{{ path('admin_document_tags_edit', {id: document.id}) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Tags bearbeiten
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ path('admin_documents') }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
@@ -96,26 +98,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
{% if is_granted('ROLE_EDITOR') or 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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Neue Version
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
{% 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) }}">
|
||||
<button class="btn btn-sm btn-outline-danger">
|
||||
Dokument löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -128,10 +134,12 @@
|
||||
<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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a href="{{ path('admin_document_tags_edit', {id: document.id}) }}"
|
||||
class="btn btn-sm btn-outline-light">
|
||||
Bearbeiten
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if document.tags is empty %}
|
||||
@@ -172,7 +180,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
Neue Version
|
||||
@@ -258,7 +266,7 @@
|
||||
<td class="text-end">
|
||||
<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') %}
|
||||
{% if version.ingestStatus in ['PENDING', 'FAILED'] and is_granted('ROLE_EDITOR') %}
|
||||
<form method="post"
|
||||
action="{{ path('admin_document_version_ingest', {versionId: version.id}) }}"
|
||||
class="d-inline"
|
||||
@@ -276,7 +284,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form method="post"
|
||||
action="{{ path('admin_document_version_activate', {versionId: version.id}) }}"
|
||||
class="d-inline"
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3"><i class="bi bi-search"></i> Indexierungsprofile</h1>
|
||||
|
||||
<a class="btn btn-sm btn-outline-info"
|
||||
href="{{ path('admin_ingest_profile_create') }}">
|
||||
Neues Profil anlegen
|
||||
</a>
|
||||
{% if is_granted('ROLE_SUPER_ADMIN') %}
|
||||
<a class="btn btn-sm btn-outline-info"
|
||||
href="{{ path('admin_ingest_profile_create') }}">
|
||||
Neues Profil anlegen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ========================================================= #}
|
||||
|
||||
@@ -106,9 +106,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<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>
|
||||
|
||||
<form method="post" action="{{ path('admin_tags_create') }}" class="row g-3">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('admin_tag_create') }}"/>
|
||||
@@ -154,8 +155,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card bg-black border-secondary text-light shadow-sm">
|
||||
<div class="card-body">
|
||||
@@ -207,23 +209,27 @@
|
||||
</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>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<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) }}"/>
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Tag wirklich löschen? Zuweisungen werden entfernt.')">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">Nur Ansicht</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
|
||||
Reference in New Issue
Block a user