stash light

This commit is contained in:
team 1
2026-02-12 10:03:52 +01:00
parent 5b650a8f28
commit 0bb0c0b42f
51 changed files with 6864 additions and 72 deletions

10
.env
View File

@@ -34,3 +34,13 @@ AI_DEBUG=false
AI_LOG_PROMPT=false AI_LOG_PROMPT=false
AI_LOG_CONTEXT=false AI_LOG_CONTEXT=false
###< AI Agent Debug ### ###< AI Agent Debug ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###

View File

@@ -8,12 +8,17 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-curl": "*", "ext-curl": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6",
"symfony/console": "^7.4", "symfony/console": "^7.4",
"symfony/dotenv": "^7.4", "symfony/dotenv": "^7.4",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "^7.4", "symfony/framework-bundle": "^7.4",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/runtime": "^7.4", "symfony/runtime": "^7.4",
"symfony/security-bundle": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*", "symfony/uid": "7.4.*",
"symfony/yaml": "^7.4" "symfony/yaml": "^7.4"
}, },
@@ -53,7 +58,11 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "7.4.*",
"docker": false
} }
},
"require-dev": {
"symfony/maker-bundle": "^1.66"
} }
} }

2805
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,9 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
]; ];

View File

@@ -0,0 +1,54 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -0,0 +1,47 @@
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
# 🔐 Admin zuerst!
admin:
pattern: ^/admin
lazy: true
provider: app_user_provider
form_login:
login_path: admin_login
check_path: admin_login
default_target_path: admin_dashboard
logout:
path: admin_logout
target: admin_login
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /admin
# 🌍 Alles andere ist public (Chat etc.)
main:
pattern: ^/
security: false
role_hierarchy:
ROLE_SUPER_ADMIN: [ROLE_KNOWLEDGE_ADMIN, ROLE_EDITOR, ROLE_USER]
ROLE_KNOWLEDGE_ADMIN: [ROLE_EDITOR, ROLE_USER]
ROLE_EDITOR: [ROLE_USER]
access_control:
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin/logout$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_USER }

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -369,7 +369,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* property_access?: bool|array{ // Property access configuration * property_access?: bool|array{ // Property access configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* magic_call?: bool|Param, // Default: false * magic_call?: bool|Param, // Default: false
* magic_get?: bool|Param, // Default: true * magic_get?: bool|Param, // Default: true
* magic_set?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true
@@ -377,11 +377,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true * throw_exception_on_invalid_property_path?: bool|Param, // Default: true
* }, * },
* type_info?: bool|array{ // Type info configuration * type_info?: bool|array{ // Type info configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* aliases?: array<string, scalar|null|Param>, * aliases?: array<string, scalar|null|Param>,
* }, * },
* property_info?: bool|array{ // Property info configuration * property_info?: bool|array{ // Property info configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
* }, * },
* cache?: array{ // Cache configuration * cache?: array{ // Cache configuration
@@ -833,18 +833,629 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* }>, * }>,
* } * }
* @psalm-type SecurityConfig = array{
* access_denied_url?: scalar|null|Param, // Default: null
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
* hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead.
* expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none"
* erase_credentials?: bool|Param, // Default: true
* access_decision_manager?: array{
* strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param,
* service?: scalar|null|Param,
* strategy_service?: scalar|null|Param,
* allow_if_all_abstain?: bool|Param, // Default: false
* allow_if_equal_granted_denied?: bool|Param, // Default: true
* },
* password_hashers?: array<string, string|array{ // Default: []
* algorithm?: scalar|null|Param,
* migrate_from?: list<scalar|null|Param>,
* hash_algorithm?: scalar|null|Param, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512"
* key_length?: scalar|null|Param, // Default: 40
* ignore_case?: bool|Param, // Default: false
* encode_as_base64?: bool|Param, // Default: true
* iterations?: scalar|null|Param, // Default: 5000
* cost?: int|Param, // Default: null
* memory_cost?: scalar|null|Param, // Default: null
* time_cost?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* }>,
* providers?: array<string, array{ // Default: []
* id?: scalar|null|Param,
* chain?: array{
* providers?: list<scalar|null|Param>,
* },
* memory?: array{
* users?: array<string, array{ // Default: []
* password?: scalar|null|Param, // Default: null
* roles?: list<scalar|null|Param>,
* }>,
* },
* ldap?: array{
* service: scalar|null|Param,
* base_dn: scalar|null|Param,
* search_dn?: scalar|null|Param, // Default: null
* search_password?: scalar|null|Param, // Default: null
* extra_fields?: list<scalar|null|Param>,
* default_roles?: list<scalar|null|Param>,
* role_fetcher?: scalar|null|Param, // Default: null
* uid_key?: scalar|null|Param, // Default: "sAMAccountName"
* filter?: scalar|null|Param, // Default: "({uid_key}={user_identifier})"
* password_attribute?: scalar|null|Param, // Default: null
* },
* entity?: array{
* class: scalar|null|Param, // The full entity class name of your user class.
* property?: scalar|null|Param, // Default: null
* manager_name?: scalar|null|Param, // Default: null
* },
* }>,
* firewalls: array<string, array{ // Default: []
* pattern?: scalar|null|Param,
* host?: scalar|null|Param,
* methods?: list<scalar|null|Param>,
* security?: bool|Param, // Default: true
* user_checker?: scalar|null|Param, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker"
* request_matcher?: scalar|null|Param,
* access_denied_url?: scalar|null|Param,
* access_denied_handler?: scalar|null|Param,
* entry_point?: scalar|null|Param, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface".
* provider?: scalar|null|Param,
* stateless?: bool|Param, // Default: false
* lazy?: bool|Param, // Default: false
* context?: scalar|null|Param,
* logout?: array{
* enable_csrf?: bool|null|Param, // Default: null
* csrf_token_id?: scalar|null|Param, // Default: "logout"
* csrf_parameter?: scalar|null|Param, // Default: "_csrf_token"
* csrf_token_manager?: scalar|null|Param,
* path?: scalar|null|Param, // Default: "/logout"
* target?: scalar|null|Param, // Default: "/"
* invalidate_session?: bool|Param, // Default: true
* clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>,
* delete_cookies?: array<string, array{ // Default: []
* path?: scalar|null|Param, // Default: null
* domain?: scalar|null|Param, // Default: null
* secure?: scalar|null|Param, // Default: false
* samesite?: scalar|null|Param, // Default: null
* partitioned?: scalar|null|Param, // Default: false
* }>,
* },
* switch_user?: array{
* provider?: scalar|null|Param,
* parameter?: scalar|null|Param, // Default: "_switch_user"
* role?: scalar|null|Param, // Default: "ROLE_ALLOWED_TO_SWITCH"
* target_route?: scalar|null|Param, // Default: null
* },
* required_badges?: list<scalar|null|Param>,
* custom_authenticators?: list<scalar|null|Param>,
* login_throttling?: array{
* limiter?: scalar|null|Param, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface".
* max_attempts?: int|Param, // Default: 5
* interval?: scalar|null|Param, // Default: "1 minute"
* lock_factory?: scalar|null|Param, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null
* cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter"
* storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null
* },
* x509?: array{
* provider?: scalar|null|Param,
* user?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN_Email"
* credentials?: scalar|null|Param, // Default: "SSL_CLIENT_S_DN"
* user_identifier?: scalar|null|Param, // Default: "emailAddress"
* },
* remote_user?: array{
* provider?: scalar|null|Param,
* user?: scalar|null|Param, // Default: "REMOTE_USER"
* },
* login_link?: array{
* check_route: scalar|null|Param, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_post_only?: scalar|null|Param, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
* signature_properties: list<scalar|null|Param>,
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
* used_link_cache?: scalar|null|Param, // Cache service id used to expired links of max_uses is set.
* success_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface.
* failure_handler?: scalar|null|Param, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface.
* provider?: scalar|null|Param, // The user provider to load users from.
* secret?: scalar|null|Param, // Default: "%kernel.secret%"
* always_use_default_target_path?: bool|Param, // Default: false
* default_target_path?: scalar|null|Param, // Default: "/"
* login_path?: scalar|null|Param, // Default: "/login"
* target_path_parameter?: scalar|null|Param, // Default: "_target_path"
* use_referer?: bool|Param, // Default: false
* failure_path?: scalar|null|Param, // Default: null
* failure_forward?: bool|Param, // Default: false
* failure_path_parameter?: scalar|null|Param, // Default: "_failure_path"
* },
* form_login?: array{
* provider?: scalar|null|Param,
* remember_me?: bool|Param, // Default: true
* success_handler?: scalar|null|Param,
* failure_handler?: scalar|null|Param,
* check_path?: scalar|null|Param, // Default: "/login_check"
* use_forward?: bool|Param, // Default: false
* login_path?: scalar|null|Param, // Default: "/login"
* username_parameter?: scalar|null|Param, // Default: "_username"
* password_parameter?: scalar|null|Param, // Default: "_password"
* csrf_parameter?: scalar|null|Param, // Default: "_csrf_token"
* csrf_token_id?: scalar|null|Param, // Default: "authenticate"
* enable_csrf?: bool|Param, // Default: false
* post_only?: bool|Param, // Default: true
* form_only?: bool|Param, // Default: false
* always_use_default_target_path?: bool|Param, // Default: false
* default_target_path?: scalar|null|Param, // Default: "/"
* target_path_parameter?: scalar|null|Param, // Default: "_target_path"
* use_referer?: bool|Param, // Default: false
* failure_path?: scalar|null|Param, // Default: null
* failure_forward?: bool|Param, // Default: false
* failure_path_parameter?: scalar|null|Param, // Default: "_failure_path"
* },
* form_login_ldap?: array{
* provider?: scalar|null|Param,
* remember_me?: bool|Param, // Default: true
* success_handler?: scalar|null|Param,
* failure_handler?: scalar|null|Param,
* check_path?: scalar|null|Param, // Default: "/login_check"
* use_forward?: bool|Param, // Default: false
* login_path?: scalar|null|Param, // Default: "/login"
* username_parameter?: scalar|null|Param, // Default: "_username"
* password_parameter?: scalar|null|Param, // Default: "_password"
* csrf_parameter?: scalar|null|Param, // Default: "_csrf_token"
* csrf_token_id?: scalar|null|Param, // Default: "authenticate"
* enable_csrf?: bool|Param, // Default: false
* post_only?: bool|Param, // Default: true
* form_only?: bool|Param, // Default: false
* always_use_default_target_path?: bool|Param, // Default: false
* default_target_path?: scalar|null|Param, // Default: "/"
* target_path_parameter?: scalar|null|Param, // Default: "_target_path"
* use_referer?: bool|Param, // Default: false
* failure_path?: scalar|null|Param, // Default: null
* failure_forward?: bool|Param, // Default: false
* failure_path_parameter?: scalar|null|Param, // Default: "_failure_path"
* service?: scalar|null|Param, // Default: "ldap"
* dn_string?: scalar|null|Param, // Default: "{user_identifier}"
* query_string?: scalar|null|Param,
* search_dn?: scalar|null|Param, // Default: ""
* search_password?: scalar|null|Param, // Default: ""
* },
* json_login?: array{
* provider?: scalar|null|Param,
* remember_me?: bool|Param, // Default: true
* success_handler?: scalar|null|Param,
* failure_handler?: scalar|null|Param,
* check_path?: scalar|null|Param, // Default: "/login_check"
* use_forward?: bool|Param, // Default: false
* login_path?: scalar|null|Param, // Default: "/login"
* username_path?: scalar|null|Param, // Default: "username"
* password_path?: scalar|null|Param, // Default: "password"
* },
* json_login_ldap?: array{
* provider?: scalar|null|Param,
* remember_me?: bool|Param, // Default: true
* success_handler?: scalar|null|Param,
* failure_handler?: scalar|null|Param,
* check_path?: scalar|null|Param, // Default: "/login_check"
* use_forward?: bool|Param, // Default: false
* login_path?: scalar|null|Param, // Default: "/login"
* username_path?: scalar|null|Param, // Default: "username"
* password_path?: scalar|null|Param, // Default: "password"
* service?: scalar|null|Param, // Default: "ldap"
* dn_string?: scalar|null|Param, // Default: "{user_identifier}"
* query_string?: scalar|null|Param,
* search_dn?: scalar|null|Param, // Default: ""
* search_password?: scalar|null|Param, // Default: ""
* },
* access_token?: array{
* provider?: scalar|null|Param,
* remember_me?: bool|Param, // Default: true
* success_handler?: scalar|null|Param,
* failure_handler?: scalar|null|Param,
* realm?: scalar|null|Param, // Default: null
* token_extractors?: list<scalar|null|Param>,
* token_handler: string|array{
* id?: scalar|null|Param,
* oidc_user_info?: string|array{
* base_uri: scalar|null|Param, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
* discovery?: array{ // Enable the OIDC discovery.
* cache?: array{
* id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration.
* },
* },
* claim?: scalar|null|Param, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
* client?: scalar|null|Param, // HttpClient service id to use to call the OIDC server.
* },
* oidc?: array{
* discovery?: array{ // Enable the OIDC discovery.
* base_uri: list<scalar|null|Param>,
* cache?: array{
* id: scalar|null|Param, // Cache service id to use to cache the OIDC discovery configuration.
* },
* },
* claim?: scalar|null|Param, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
* audience: scalar|null|Param, // Audience set in the token, for validation purpose.
* issuers: list<scalar|null|Param>,
* algorithm?: array<mixed>,
* algorithms: list<scalar|null|Param>,
* key?: scalar|null|Param, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key).
* keyset?: scalar|null|Param, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
* algorithms: list<scalar|null|Param>,
* keyset: scalar|null|Param, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
* },
* },
* cas?: array{
* validation_url: scalar|null|Param, // CAS server validation URL
* prefix?: scalar|null|Param, // CAS prefix // Default: "cas"
* http_client?: scalar|null|Param, // HTTP Client service // Default: null
* },
* oauth2?: scalar|null|Param,
* },
* },
* http_basic?: array{
* provider?: scalar|null|Param,
* realm?: scalar|null|Param, // Default: "Secured Area"
* },
* http_basic_ldap?: array{
* provider?: scalar|null|Param,
* realm?: scalar|null|Param, // Default: "Secured Area"
* service?: scalar|null|Param, // Default: "ldap"
* dn_string?: scalar|null|Param, // Default: "{user_identifier}"
* query_string?: scalar|null|Param,
* search_dn?: scalar|null|Param, // Default: ""
* search_password?: scalar|null|Param, // Default: ""
* },
* remember_me?: array{
* secret?: scalar|null|Param, // Default: "%kernel.secret%"
* service?: scalar|null|Param,
* user_providers?: list<scalar|null|Param>,
* catch_exceptions?: bool|Param, // Default: true
* signature_properties?: list<scalar|null|Param>,
* token_provider?: string|array{
* service?: scalar|null|Param, // The service ID of a custom remember-me token provider.
* doctrine?: bool|array{
* enabled?: bool|Param, // Default: false
* connection?: scalar|null|Param, // Default: null
* },
* },
* token_verifier?: scalar|null|Param, // The service ID of a custom rememberme token verifier.
* name?: scalar|null|Param, // Default: "REMEMBERME"
* lifetime?: int|Param, // Default: 31536000
* path?: scalar|null|Param, // Default: "/"
* domain?: scalar|null|Param, // Default: null
* secure?: true|false|"auto"|Param, // Default: null
* httponly?: bool|Param, // Default: true
* samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
* always_remember_me?: bool|Param, // Default: false
* remember_me_parameter?: scalar|null|Param, // Default: "_remember_me"
* },
* }>,
* access_control?: list<array{ // Default: []
* request_matcher?: scalar|null|Param, // Default: null
* requires_channel?: scalar|null|Param, // Default: null
* path?: scalar|null|Param, // Use the urldecoded format. // Default: null
* host?: scalar|null|Param, // Default: null
* port?: int|Param, // Default: null
* ips?: list<scalar|null|Param>,
* attributes?: array<string, scalar|null|Param>,
* route?: scalar|null|Param, // Default: null
* methods?: list<scalar|null|Param>,
* allow_if?: scalar|null|Param, // Default: null
* roles?: list<scalar|null|Param>,
* }>,
* role_hierarchy?: array<string, string|list<scalar|null|Param>>,
* }
* @psalm-type DoctrineConfig = array{
* dbal?: array{
* default_connection?: scalar|null|Param,
* types?: array<string, string|array{ // Default: []
* class: scalar|null|Param,
* commented?: bool|Param, // Deprecated: The doctrine-bundle type commenting features were removed; the corresponding config parameter was deprecated in 2.0 and will be dropped in 3.0.
* }>,
* driver_schemes?: array<string, scalar|null|Param>,
* connections?: array<string, array{ // Default: []
* url?: scalar|null|Param, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters
* dbname?: scalar|null|Param,
* host?: scalar|null|Param, // Defaults to "localhost" at runtime.
* port?: scalar|null|Param, // Defaults to null at runtime.
* user?: scalar|null|Param, // Defaults to "root" at runtime.
* password?: scalar|null|Param, // Defaults to null at runtime.
* override_url?: bool|Param, // Deprecated: The "doctrine.dbal.override_url" configuration key is deprecated.
* dbname_suffix?: scalar|null|Param, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform
* application_name?: scalar|null|Param,
* charset?: scalar|null|Param,
* path?: scalar|null|Param,
* memory?: bool|Param,
* unix_socket?: scalar|null|Param, // The unix socket to use for MySQL
* persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver
* protocol?: scalar|null|Param, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)
* service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle
* servicename?: scalar|null|Param, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|null|Param, // The session mode to use for the oci8 driver
* server?: scalar|null|Param, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|null|Param, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* sslmode?: scalar|null|Param, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|null|Param, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|null|Param, // The path to the SSL client certificate file for PostgreSQL.
* sslkey?: scalar|null|Param, // The path to the SSL client key file for PostgreSQL.
* sslcrl?: scalar|null|Param, // The file name of the SSL certificate revocation list for PostgreSQL.
* pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver
* MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver
* use_savepoints?: bool|Param, // Use savepoints for nested transactions
* instancename?: scalar|null|Param, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.
* connectstring?: scalar|null|Param, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\DBAL\Connection will no longer function as expected.
* driver?: scalar|null|Param, // Default: "pdo_mysql"
* platform_service?: scalar|null|Param, // Deprecated: The "platform_service" configuration key is deprecated since doctrine-bundle 2.9. DBAL 4 will not support setting a custom platform via connection params anymore.
* auto_commit?: bool|Param,
* schema_filter?: scalar|null|Param,
* logging?: bool|Param, // Default: true
* profiling?: bool|Param, // Default: true
* profiling_collect_backtrace?: bool|Param, // Enables collecting backtraces when profiling is enabled // Default: false
* profiling_collect_schema_errors?: bool|Param, // Enables collecting schema errors when profiling is enabled // Default: true
* disable_type_comments?: bool|Param,
* server_version?: scalar|null|Param,
* idle_connection_ttl?: int|Param, // Default: 600
* driver_class?: scalar|null|Param,
* wrapper_class?: scalar|null|Param,
* keep_slave?: bool|Param, // Deprecated: The "keep_slave" configuration key is deprecated since doctrine-bundle 2.2. Use the "keep_replica" configuration key instead.
* keep_replica?: bool|Param,
* options?: array<string, mixed>,
* mapping_types?: array<string, scalar|null|Param>,
* default_table_options?: array<string, scalar|null|Param>,
* schema_manager_factory?: scalar|null|Param, // Default: "doctrine.dbal.default_schema_manager_factory"
* result_cache?: scalar|null|Param,
* slaves?: array<string, array{ // Default: []
* url?: scalar|null|Param, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters
* dbname?: scalar|null|Param,
* host?: scalar|null|Param, // Defaults to "localhost" at runtime.
* port?: scalar|null|Param, // Defaults to null at runtime.
* user?: scalar|null|Param, // Defaults to "root" at runtime.
* password?: scalar|null|Param, // Defaults to null at runtime.
* override_url?: bool|Param, // Deprecated: The "doctrine.dbal.override_url" configuration key is deprecated.
* dbname_suffix?: scalar|null|Param, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform
* application_name?: scalar|null|Param,
* charset?: scalar|null|Param,
* path?: scalar|null|Param,
* memory?: bool|Param,
* unix_socket?: scalar|null|Param, // The unix socket to use for MySQL
* persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver
* protocol?: scalar|null|Param, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)
* service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle
* servicename?: scalar|null|Param, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|null|Param, // The session mode to use for the oci8 driver
* server?: scalar|null|Param, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|null|Param, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* sslmode?: scalar|null|Param, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|null|Param, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|null|Param, // The path to the SSL client certificate file for PostgreSQL.
* sslkey?: scalar|null|Param, // The path to the SSL client key file for PostgreSQL.
* sslcrl?: scalar|null|Param, // The file name of the SSL certificate revocation list for PostgreSQL.
* pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver
* MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver
* use_savepoints?: bool|Param, // Use savepoints for nested transactions
* instancename?: scalar|null|Param, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.
* connectstring?: scalar|null|Param, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\DBAL\Connection will no longer function as expected.
* }>,
* replicas?: array<string, array{ // Default: []
* url?: scalar|null|Param, // A URL with connection information; any parameter value parsed from this string will override explicitly set parameters
* dbname?: scalar|null|Param,
* host?: scalar|null|Param, // Defaults to "localhost" at runtime.
* port?: scalar|null|Param, // Defaults to null at runtime.
* user?: scalar|null|Param, // Defaults to "root" at runtime.
* password?: scalar|null|Param, // Defaults to null at runtime.
* override_url?: bool|Param, // Deprecated: The "doctrine.dbal.override_url" configuration key is deprecated.
* dbname_suffix?: scalar|null|Param, // Adds the given suffix to the configured database name, this option has no effects for the SQLite platform
* application_name?: scalar|null|Param,
* charset?: scalar|null|Param,
* path?: scalar|null|Param,
* memory?: bool|Param,
* unix_socket?: scalar|null|Param, // The unix socket to use for MySQL
* persistent?: bool|Param, // True to use as persistent connection for the ibm_db2 driver
* protocol?: scalar|null|Param, // The protocol to use for the ibm_db2 driver (default to TCPIP if omitted)
* service?: bool|Param, // True to use SERVICE_NAME as connection parameter instead of SID for Oracle
* servicename?: scalar|null|Param, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
* sessionMode?: scalar|null|Param, // The session mode to use for the oci8 driver
* server?: scalar|null|Param, // The name of a running database server to connect to for SQL Anywhere.
* default_dbname?: scalar|null|Param, // Override the default database (postgres) to connect to for PostgreSQL connexion.
* sslmode?: scalar|null|Param, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
* sslrootcert?: scalar|null|Param, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
* sslcert?: scalar|null|Param, // The path to the SSL client certificate file for PostgreSQL.
* sslkey?: scalar|null|Param, // The path to the SSL client key file for PostgreSQL.
* sslcrl?: scalar|null|Param, // The file name of the SSL certificate revocation list for PostgreSQL.
* pooled?: bool|Param, // True to use a pooled server with the oci8/pdo_oracle driver
* MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver
* use_savepoints?: bool|Param, // Use savepoints for nested transactions
* instancename?: scalar|null|Param, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance.
* connectstring?: scalar|null|Param, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\DBAL\Connection will no longer function as expected.
* }>,
* }>,
* },
* orm?: array{
* default_entity_manager?: scalar|null|Param,
* auto_generate_proxy_classes?: scalar|null|Param, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false
* enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true
* enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false
* proxy_dir?: scalar|null|Param, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies"
* proxy_namespace?: scalar|null|Param, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies"
* controller_resolver?: bool|array{
* enabled?: bool|Param, // Default: true
* auto_mapping?: bool|null|Param, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null
* evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false
* },
* entity_managers?: array<string, array{ // Default: []
* query_cache_driver?: string|array{
* type?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* pool?: scalar|null|Param,
* },
* metadata_cache_driver?: string|array{
* type?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* pool?: scalar|null|Param,
* },
* result_cache_driver?: string|array{
* type?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* pool?: scalar|null|Param,
* },
* entity_listeners?: array{
* entities?: array<string, array{ // Default: []
* listeners?: array<string, array{ // Default: []
* events?: list<array{ // Default: []
* type?: scalar|null|Param,
* method?: scalar|null|Param, // Default: null
* }>,
* }>,
* }>,
* },
* connection?: scalar|null|Param,
* class_metadata_factory_name?: scalar|null|Param, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory"
* default_repository_class?: scalar|null|Param, // Default: "Doctrine\\ORM\\EntityRepository"
* auto_mapping?: scalar|null|Param, // Default: false
* naming_strategy?: scalar|null|Param, // Default: "doctrine.orm.naming_strategy.default"
* quote_strategy?: scalar|null|Param, // Default: "doctrine.orm.quote_strategy.default"
* typed_field_mapper?: scalar|null|Param, // Default: "doctrine.orm.typed_field_mapper.default"
* entity_listener_resolver?: scalar|null|Param, // Default: null
* fetch_mode_subselect_batch_size?: scalar|null|Param,
* repository_factory?: scalar|null|Param, // Default: "doctrine.orm.container_repository_factory"
* schema_ignore_classes?: list<scalar|null|Param>,
* report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true
* validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false
* second_level_cache?: array{
* region_cache_driver?: string|array{
* type?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* pool?: scalar|null|Param,
* },
* region_lock_lifetime?: scalar|null|Param, // Default: 60
* log_enabled?: bool|Param, // Default: true
* region_lifetime?: scalar|null|Param, // Default: 3600
* enabled?: bool|Param, // Default: true
* factory?: scalar|null|Param,
* regions?: array<string, array{ // Default: []
* cache_driver?: string|array{
* type?: scalar|null|Param, // Default: null
* id?: scalar|null|Param,
* pool?: scalar|null|Param,
* },
* lock_path?: scalar|null|Param, // Default: "%kernel.cache_dir%/doctrine/orm/slc/filelock"
* lock_lifetime?: scalar|null|Param, // Default: 60
* type?: scalar|null|Param, // Default: "default"
* lifetime?: scalar|null|Param, // Default: 0
* service?: scalar|null|Param,
* name?: scalar|null|Param,
* }>,
* loggers?: array<string, array{ // Default: []
* name?: scalar|null|Param,
* service?: scalar|null|Param,
* }>,
* },
* hydrators?: array<string, scalar|null|Param>,
* mappings?: array<string, bool|string|array{ // Default: []
* mapping?: scalar|null|Param, // Default: true
* type?: scalar|null|Param,
* dir?: scalar|null|Param,
* alias?: scalar|null|Param,
* prefix?: scalar|null|Param,
* is_bundle?: bool|Param,
* }>,
* dql?: array{
* string_functions?: array<string, scalar|null|Param>,
* numeric_functions?: array<string, scalar|null|Param>,
* datetime_functions?: array<string, scalar|null|Param>,
* },
* filters?: array<string, string|array{ // Default: []
* class: scalar|null|Param,
* enabled?: bool|Param, // Default: false
* parameters?: array<string, mixed>,
* }>,
* identity_generation_preferences?: array<string, scalar|null|Param>,
* }>,
* resolve_target_entities?: array<string, scalar|null|Param>,
* },
* }
* @psalm-type DoctrineMigrationsConfig = array{
* enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false
* migrations_paths?: array<string, scalar|null|Param>,
* services?: array<string, scalar|null|Param>,
* factories?: array<string, scalar|null|Param>,
* storage?: array{ // Storage to use for migration status metadata.
* table_storage?: array{ // The default metadata storage, implemented as a table in the database.
* table_name?: scalar|null|Param, // Default: null
* version_column_name?: scalar|null|Param, // Default: null
* version_column_length?: scalar|null|Param, // Default: null
* executed_at_column_name?: scalar|null|Param, // Default: null
* execution_time_column_name?: scalar|null|Param, // Default: null
* },
* },
* migrations?: list<scalar|null|Param>,
* connection?: scalar|null|Param, // Connection name to use for the migrations database. // Default: null
* em?: scalar|null|Param, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null
* all_or_nothing?: scalar|null|Param, // Run all migrations in a transaction. // Default: false
* check_database_platform?: scalar|null|Param, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true
* custom_template?: scalar|null|Param, // Custom template path for generated migration classes. // Default: null
* organize_migrations?: scalar|null|Param, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false
* enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false
* transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true
* }
* @psalm-type MakerConfig = array{
* root_namespace?: scalar|null|Param, // Default: "App"
* generate_final_classes?: bool|Param, // Default: true
* generate_final_entities?: bool|Param, // Default: false
* }
* @psalm-type TwigConfig = array{
* form_themes?: list<scalar|null|Param>,
* globals?: array<string, array{ // Default: []
* id?: scalar|null|Param,
* type?: scalar|null|Param,
* value?: mixed,
* }>,
* autoescape_service?: scalar|null|Param, // Default: null
* autoescape_service_method?: scalar|null|Param, // Default: null
* base_template_class?: scalar|null|Param, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated.
* cache?: scalar|null|Param, // Default: true
* charset?: scalar|null|Param, // Default: "%kernel.charset%"
* debug?: bool|Param, // Default: "%kernel.debug%"
* strict_variables?: bool|Param, // Default: "%kernel.debug%"
* auto_reload?: scalar|null|Param,
* optimizations?: int|Param,
* default_path?: scalar|null|Param, // The default path used to load templates. // Default: "%kernel.project_dir%/templates"
* file_name_pattern?: list<scalar|null|Param>,
* paths?: array<string, mixed>,
* date?: array{ // The default format options used by the date filter.
* format?: scalar|null|Param, // Default: "F j, Y H:i"
* interval_format?: scalar|null|Param, // Default: "%d days"
* timezone?: scalar|null|Param, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null
* },
* number_format?: array{ // The default format options for the number_format filter.
* decimals?: int|Param, // Default: 0
* decimal_point?: scalar|null|Param, // Default: "."
* thousands_separator?: scalar|null|Param, // Default: ","
* },
* mailer?: array{
* html_to_text_converter?: scalar|null|Param, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null
* },
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* twig?: TwigConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* maker?: MakerConfig,
* twig?: TwigConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -852,6 +1463,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* twig?: TwigConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -859,6 +1474,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* services?: ServicesConfig, * services?: ServicesConfig,
* framework?: FrameworkConfig, * framework?: FrameworkConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* security?: SecurityConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* twig?: TwigConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,

View File

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@@ -4,6 +4,14 @@
# https://symfony.com/doc/current/best_practices.html # https://symfony.com/doc/current/best_practices.html
parameters: parameters:
mto.index.chunk_size: 800
mto.index.chunk_overlap: 100
mto.index.embedding_model: 'nomic-embed-text'
mto.index.embedding_dimension: 768
mto.index.scoring_version: 1
mto.vector.python_bin: '/var/www/html/src/Vector/.venv/bin/python'
mto.vector.ingest_script: '/src/Vector/vector_ingest.py'
mto.vector.timeout: 600
services: services:
@@ -104,3 +112,19 @@ services:
App\Command\VectorInstallCommand: App\Command\VectorInstallCommand:
arguments: arguments:
$vectorDir: '%kernel.project_dir%/src/Vector' $vectorDir: '%kernel.project_dir%/src/Vector'
App\Index\IndexConfiguration:
arguments:
$chunkSize: '%mto.index.chunk_size%'
$chunkOverlap: '%mto.index.chunk_overlap%'
$embeddingModel: '%mto.index.embedding_model%'
$embeddingDimension: '%mto.index.embedding_dimension%'
$scoringVersion: '%mto.index.scoring_version%'
$indexFormat: 'ndjson'
$vectorBackend: 'faiss'
App\Vector\VectorIndexBuilder:
arguments:
$pythonBin: '%mto.vector.python_bin%'
$relativeScriptPath: '%mto.vector.ingest_script%'
$timeoutSeconds: '%mto.vector.timeout%'

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260211134954 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE user (id BINARY(16) NOT NULL, email VARCHAR(180) NOT NULL, password VARCHAR(255) NOT NULL, roles JSON NOT NULL, is_active TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE user');
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260211142601 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE document (id BINARY(16) NOT NULL, title VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, created_by_id BINARY(16) NOT NULL, current_version_id BINARY(16) DEFAULT NULL, INDEX IDX_D8698A76B03A8386 (created_by_id), INDEX IDX_D8698A769407EE77 (current_version_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE document_version (id BINARY(16) NOT NULL, version_number INT NOT NULL, file_path VARCHAR(255) NOT NULL, checksum VARCHAR(64) NOT NULL, ingest_status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, is_active TINYINT NOT NULL, document_id BINARY(16) NOT NULL, created_by_id BINARY(16) NOT NULL, INDEX IDX_1B73751FC33F7837 (document_id), INDEX IDX_1B73751FB03A8386 (created_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE document ADD CONSTRAINT FK_D8698A76B03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE document ADD CONSTRAINT FK_D8698A769407EE77 FOREIGN KEY (current_version_id) REFERENCES document_version (id)');
$this->addSql('ALTER TABLE document_version ADD CONSTRAINT FK_1B73751FC33F7837 FOREIGN KEY (document_id) REFERENCES document (id)');
$this->addSql('ALTER TABLE document_version ADD CONSTRAINT FK_1B73751FB03A8386 FOREIGN KEY (created_by_id) REFERENCES user (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE document DROP FOREIGN KEY FK_D8698A76B03A8386');
$this->addSql('ALTER TABLE document DROP FOREIGN KEY FK_D8698A769407EE77');
$this->addSql('ALTER TABLE document_version DROP FOREIGN KEY FK_1B73751FC33F7837');
$this->addSql('ALTER TABLE document_version DROP FOREIGN KEY FK_1B73751FB03A8386');
$this->addSql('DROP TABLE document');
$this->addSql('DROP TABLE document_version');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260211145114 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE ingest_job (id BINARY(16) NOT NULL, type VARCHAR(30) NOT NULL, status VARCHAR(20) NOT NULL, document_id VARCHAR(255) DEFAULT NULL, document_version_id VARCHAR(255) DEFAULT NULL, started_at DATETIME NOT NULL, finished_at DATETIME DEFAULT NULL, log_path VARCHAR(255) DEFAULT NULL, error_message LONGTEXT DEFAULT NULL, started_by_id BINARY(16) DEFAULT NULL, INDEX IDX_4F6AC8649740C9D5 (started_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE ingest_job ADD CONSTRAINT FK_4F6AC8649740C9D5 FOREIGN KEY (started_by_id) REFERENCES user (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE ingest_job DROP FOREIGN KEY FK_4F6AC8649740C9D5');
$this->addSql('DROP TABLE ingest_job');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260211145121 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE ingest_job (id BINARY(16) NOT NULL, type VARCHAR(30) NOT NULL, status VARCHAR(20) NOT NULL, document_id VARCHAR(255) DEFAULT NULL, document_version_id VARCHAR(255) DEFAULT NULL, started_at DATETIME NOT NULL, finished_at DATETIME DEFAULT NULL, log_path VARCHAR(255) DEFAULT NULL, error_message LONGTEXT DEFAULT NULL, started_by_id BINARY(16) DEFAULT NULL, INDEX IDX_4F6AC8649740C9D5 (started_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE ingest_job ADD CONSTRAINT FK_4F6AC8649740C9D5 FOREIGN KEY (started_by_id) REFERENCES user (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE ingest_job DROP FOREIGN KEY FK_4F6AC8649740C9D5');
$this->addSql('DROP TABLE ingest_job');
}
}

View File

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

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Command;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'mto:agent:user:create',
description: 'Creates a new admin user'
)]
class CreateUserCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private UserPasswordHasherInterface $passwordHasher
)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
// =============================
// Email
// =============================
$emailQuestion = new Question('E-Mail: ');
$emailQuestion->setValidator(function ($value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.');
}
return strtolower(trim($value));
});
$email = $helper->ask($input, $output, $emailQuestion);
// Prüfen ob User existiert
$existingUser = $this->em
->getRepository(User::class)
->findOneBy(['email' => $email]);
if ($existingUser) {
$output->writeln('<error>User already exists.</error>');
return Command::FAILURE;
}
// =============================
// Passwort
// =============================
$passwordQuestion = new Question('Password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$plainPassword = $helper->ask($input, $output, $passwordQuestion);
if (strlen($plainPassword) < 8) {
$output->writeln('<error>Password must be at least 8 characters.</error>');
return Command::FAILURE;
}
// =============================
// Rolle auswählen
// =============================
$roleQuestion = new ChoiceQuestion(
'Select role:',
[
'ROLE_SUPER_ADMIN',
'ROLE_KNOWLEDGE_ADMIN',
'ROLE_EDITOR',
'ROLE_USER',
],
0
);
$role = $helper->ask($input, $output, $roleQuestion);
// =============================
// User erzeugen
// =============================
$user = new User();
$user->setEmail($email);
$user->setRoles([$role]);
$hashedPassword = $this->passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashedPassword);
$this->em->persist($user);
$this->em->flush();
$output->writeln('<info>User created successfully.</info>');
$output->writeln('Email: ' . $email);
$output->writeln('Role: ' . $role);
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Controller\Admin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class DashboardController extends AbstractController
{
#[Route('/admin', name: 'admin_dashboard')]
public function index(): Response
{
return $this->render('admin/dashboard/index.html.twig');
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Document;
use App\Entity\DocumentVersion;
use App\Service\DocumentService;
use App\Service\IngestOrchestrator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
#[Route('/admin/documents')]
class DocumentController extends AbstractController
{
#[Route('', name: 'admin_documents')]
public function index(EntityManagerInterface $em): Response
{
$documents = $em->getRepository(Document::class)
->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/document/index.html.twig', [
'documents' => $documents
]);
}
#[Route(
'/{id}',
name: 'admin_document_show',
requirements: ['id' => '[0-9a-fA-F\-]{36}']
)]
public function show(string $id, EntityManagerInterface $em): Response
{
try {
$uuid = Uuid::fromString($id);
} catch (\Exception $e) {
throw new NotFoundHttpException();
}
$document = $em->getRepository(Document::class)->find($uuid);
if (!$document) {
throw new NotFoundHttpException();
}
return $this->render('admin/document/show.html.twig', [
'document' => $document
]);
}
#[Route('/new', name: 'admin_document_new')]
public function new(Request $request, DocumentService $documentService): Response
{
if ($request->isMethod('POST')) {
$title = $request->request->get('title');
$file = $request->files->get('file');
if (!$file || !$title) {
$this->addFlash('error', 'Titel und Datei sind erforderlich.');
return $this->redirectToRoute('admin_document_new');
}
$uploadDir = $this->getParameter('kernel.project_dir') . '/var/knowledge/uploads';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$newFilename = uniqid() . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException $e) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
$documentService->createDocument(
$title,
$filePath,
$this->getUser()
);
return $this->redirectToRoute('admin_documents');
}
return $this->render('admin/document/new.html.twig');
}
#[Route('/{id}/version/new', name: 'admin_document_version_new', requirements: ['id' => '[0-9a-fA-F\-]{36}'])]
public function newVersion(
string $id,
Request $request,
EntityManagerInterface $em,
DocumentService $documentService
): Response {
$document = $em->getRepository(Document::class)->find($id);
if (!$document) {
throw $this->createNotFoundException();
}
if ($request->isMethod('POST')) {
$file = $request->files->get('file');
if (!$file) {
$this->addFlash('error', 'Datei ist erforderlich.');
return $this->redirectToRoute('admin_document_version_new', ['id' => $id]);
}
$uploadDir = $this->getParameter('kernel.project_dir') . '/var/knowledge/uploads';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$newFilename = uniqid() . '_' . $file->getClientOriginalName();
try {
$file->move($uploadDir, $newFilename);
} catch (FileException $e) {
throw new \RuntimeException('File upload failed.');
}
$filePath = $uploadDir . '/' . $newFilename;
$documentService->addVersion(
$document,
$filePath,
$this->getUser()
);
return $this->redirectToRoute('admin_document_show', ['id' => $id]);
}
return $this->render('admin/document/new_version.html.twig', [
'document' => $document
]);
}
#[Route(
'/version/{versionId}/activate',
name: 'admin_document_version_activate',
requirements: ['versionId' => '[0-9a-fA-F\-]{36}'],
methods: ['POST']
)]
public function activateVersion(
string $versionId,
Request $request,
EntityManagerInterface $em,
DocumentService $documentService
): RedirectResponse {
if (!$this->isCsrfTokenValid('activate_version', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
if (!$version) {
throw $this->createNotFoundException();
}
$documentService->activateVersion($version);
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
]);
}
#[Route(
'/version/{versionId}/ingest',
name: 'admin_document_version_ingest',
methods: ['POST'],
requirements: ['versionId' => '[0-9a-fA-F\-]{36}']
)]
public function ingestVersion(
string $versionId,
Request $request,
EntityManagerInterface $em,
IngestOrchestrator $orchestrator
): RedirectResponse {
if (!$this->isCsrfTokenValid('ingest_version', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$version = $em->getRepository(DocumentVersion::class)->find($versionId);
if (!$version) {
throw $this->createNotFoundException();
}
$orchestrator->runForVersion(
$version,
$this->getUser(),
true // erstmal DryRun
);
return $this->redirectToRoute('admin_document_show', [
'id' => $version->getDocument()->getId()
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Controller\Admin;
use App\Entity\IngestJob;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use App\Ingest\IngestFlow;
use Symfony\Component\HttpFoundation\RedirectResponse;
#[Route('/admin/jobs')]
class IngestJobController extends AbstractController
{
#[Route('', name: 'admin_jobs')]
public function index(EntityManagerInterface $em): Response
{
$jobs = $em->getRepository(IngestJob::class)
->findBy([], ['startedAt' => 'DESC']);
return $this->render('admin/job/index.html.twig', [
'jobs' => $jobs
]);
}
#[Route(
'/{id}',
name: 'admin_job_show',
requirements: ['id' => '[0-9a-fA-F\-]{36}']
)]
public function show(string $id, EntityManagerInterface $em): Response
{
$job = $em->getRepository(IngestJob::class)->find($id);
if (!$job) {
throw new NotFoundHttpException();
}
return $this->render('admin/job/show.html.twig', [
'job' => $job
]);
}
#[Route('/global-reindex', name: 'admin_global_reindex', methods: ['POST'])]
public function globalReindex(
IngestFlow $flow
): RedirectResponse {
$this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');
$flow->globalReindex($this->getUser());
return $this->redirectToRoute('admin_jobs');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Controller\Admin;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
final class SecurityController extends AbstractController
{
#[Route('/admin/login', name: 'admin_login')]
public function login(AuthenticationUtils $authUtils): Response
{
// Wenn bereits eingeloggt → direkt ins Dashboard
if ($this->getUser()) {
return $this->redirectToRoute('admin_dashboard');
}
return $this->render('admin/security/login.html.twig', [
'last_username' => $authUtils->getLastUsername(),
'error' => $authUtils->getLastAuthenticationError(),
]);
}
#[Route('/admin/logout', name: 'admin_logout')]
public function logout(): void
{
// Symfony interceptet diese Route, daher bleibt sie leer.
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

110
src/Entity/Document.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
class Document
{
public const STATUS_ACTIVE = 'ACTIVE';
public const STATUS_ARCHIVED = 'ARCHIVED';
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_ACTIVE;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private User $createdBy;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\OneToMany(mappedBy: 'document', targetEntity: DocumentVersion::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $versions;
#[ORM\ManyToOne]
private ?DocumentVersion $currentVersion = null;
public function __construct()
{
$this->id = Uuid::v4();
$this->createdAt = new \DateTimeImmutable();
$this->versions = new ArrayCollection();
}
public function getId(): Uuid
{
return $this->id;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function archive(): void
{
$this->status = self::STATUS_ARCHIVED;
}
public function getCreatedBy(): User
{
return $this->createdBy;
}
public function setCreatedBy(User $user): static
{
$this->createdBy = $user;
return $this;
}
public function getVersions(): Collection
{
return $this->versions;
}
public function addVersion(DocumentVersion $version): void
{
$this->versions->add($version);
$version->setDocument($this);
}
public function setCurrentVersion(?DocumentVersion $version): void
{
$this->currentVersion = $version;
}
public function getCurrentVersion(): ?DocumentVersion
{
return $this->currentVersion;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use App\Repository\DocumentVersionRepository;
#[ORM\Entity(repositoryClass: DocumentVersionRepository::class)]
class DocumentVersion
{
public const INGEST_PENDING = 'PENDING';
public const INGEST_RUNNING = 'RUNNING';
public const INGEST_INDEXED = 'INDEXED';
public const INGEST_FAILED = 'FAILED';
public const INGEST_STATUSES = [
self::INGEST_PENDING,
self::INGEST_RUNNING,
self::INGEST_INDEXED,
self::INGEST_FAILED,
];
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\ManyToOne(inversedBy: 'versions')]
#[ORM\JoinColumn(nullable: false)]
private Document $document;
#[ORM\Column]
private int $versionNumber;
#[ORM\Column(length: 255)]
private string $filePath;
#[ORM\Column(length: 64)]
private string $checksum;
#[ORM\Column(length: 20)]
private string $ingestStatus = self::INGEST_PENDING;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private User $createdBy;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column]
private bool $isActive = false;
public function __construct()
{
$this->id = Uuid::v4();
$this->createdAt = new \DateTimeImmutable();
}
// =========================
// ID
// =========================
public function getId(): Uuid
{
return $this->id;
}
// =========================
// Document Relation
// =========================
public function setDocument(Document $document): void
{
$this->document = $document;
}
public function getDocument(): Document
{
return $this->document;
}
// =========================
// Version Number
// =========================
public function getVersionNumber(): int
{
return $this->versionNumber;
}
public function setVersionNumber(int $number): void
{
$this->versionNumber = $number;
}
// =========================
// File Path
// =========================
public function setFilePath(string $path): void
{
$this->filePath = $path;
}
public function getFilePath(): string
{
return $this->filePath;
}
// =========================
// Checksum
// =========================
public function setChecksum(string $checksum): void
{
$this->checksum = $checksum;
}
public function getChecksum(): string
{
return $this->checksum;
}
// =========================
// Ingest Status
// =========================
public function setIngestStatus(string $status): void
{
if (!in_array($status, self::INGEST_STATUSES, true)) {
throw new \InvalidArgumentException('Invalid ingest status.');
}
$this->ingestStatus = $status;
}
public function getIngestStatus(): string
{
return $this->ingestStatus;
}
public function isIndexed(): bool
{
return $this->ingestStatus === self::INGEST_INDEXED;
}
// =========================
// Created By
// =========================
public function setCreatedBy(User $user): void
{
$this->createdBy = $user;
}
public function getCreatedBy(): User
{
return $this->createdBy;
}
// =========================
// Created At
// =========================
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
// =========================
// Active Flag
// =========================
public function setActive(bool $active): void
{
$this->isActive = $active;
}
public function isActive(): bool
{
return $this->isActive;
}
}

101
src/Entity/IngestJob.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class IngestJob
{
public const TYPE_DOCUMENT = 'DOCUMENT';
public const TYPE_GLOBAL_REINDEX = 'GLOBAL_REINDEX';
public const STATUS_RUNNING = 'RUNNING';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public const STATUS_ABORTED = 'ABORTED';
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(length: 30)]
private string $type;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_RUNNING;
#[ORM\Column(type: 'uuid', nullable: true)]
private ?Uuid $documentId = null;
#[ORM\Column(type: 'uuid', nullable: true)]
private ?Uuid $documentVersionId = null;
#[ORM\Column]
private \DateTimeImmutable $startedAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $finishedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
private ?User $startedBy = null;
#[ORM\Column(nullable: true)]
private ?string $logPath = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $errorMessage = null;
public function __construct(string $type)
{
$this->id = Uuid::v4();
$this->type = $type;
$this->startedAt = new \DateTimeImmutable();
$this->status = self::STATUS_RUNNING;
}
public function getId(): Uuid { return $this->id; }
public function getType(): string { return $this->type; }
public function getStatus(): string { return $this->status; }
public function setDocumentId(?Uuid $id): void { $this->documentId = $id; }
public function getDocumentId(): ?Uuid { return $this->documentId; }
public function setDocumentVersionId(?Uuid $id): void { $this->documentVersionId = $id; }
public function getDocumentVersionId(): ?Uuid { return $this->documentVersionId; }
public function setStartedBy(?User $user): void { $this->startedBy = $user; }
public function getStartedBy(): ?User { return $this->startedBy; }
public function setLogPath(?string $path): void { $this->logPath = $path; }
public function getLogPath(): ?string { return $this->logPath; }
public function getStartedAt(): \DateTimeImmutable { return $this->startedAt; }
public function getFinishedAt(): ?\DateTimeImmutable { return $this->finishedAt; }
public function markCompleted(): void
{
$this->status = self::STATUS_COMPLETED;
$this->finishedAt = new \DateTimeImmutable();
}
public function markFailed(string $message): void
{
$this->status = self::STATUS_FAILED;
$this->errorMessage = $message;
$this->finishedAt = new \DateTimeImmutable();
}
public function markAborted(): void
{
$this->status = self::STATUS_ABORTED;
$this->finishedAt = new \DateTimeImmutable();
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
}
}

161
src/Entity/User.php Normal file
View File

@@ -0,0 +1,161 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
#[ORM\Column(length: 180, unique: true)]
private string $email;
#[ORM\Column]
private string $password;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column]
private bool $isActive = true;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->id = Uuid::v4();
$this->createdAt = new \DateTimeImmutable();
$this->roles = ['ROLE_USER'];
}
// =========================
// Security Identifier
// =========================
public function getUserIdentifier(): string
{
return $this->email;
}
// Symfony < 6 compatibility (optional)
public function getUsername(): string
{
return $this->email;
}
// =========================
// ID
// =========================
public function getId(): Uuid
{
return $this->id;
}
// =========================
// Email
// =========================
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = strtolower($email);
return $this;
}
// =========================
// Password
// =========================
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
// =========================
// Roles
// =========================
public function getRoles(): array
{
$roles = $this->roles;
// Jeder User hat mindestens ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
// =========================
// Active Status
// =========================
public function isActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): static
{
$this->isActive = $isActive;
return $this;
}
// =========================
// Timestamps
// =========================
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
#[ORM\PreUpdate]
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
// =========================
// Erase Credentials (Pflicht)
// =========================
public function eraseCredentials(): void
{
// Falls später sensible Daten gespeichert werden
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Index;
/**
* Beschreibt die "Struktur" des Index (nicht den Inhalt).
* Diese Werte müssen bei lokalem Ingest mit index_meta.json kompatibel sein,
* sonst muss ein Global Reindex erzwungen werden.
*/
final class IndexConfiguration
{
public function __construct(
private readonly int $chunkSize,
private readonly int $chunkOverlap,
private readonly string $embeddingModel,
private readonly int $embeddingDimension,
private readonly int $scoringVersion,
private readonly string $indexFormat = 'ndjson', // bindend: 'ndjson'
private readonly string $vectorBackend = 'faiss', // informativ
)
{
if ($this->chunkSize <= 0) {
throw new \InvalidArgumentException('chunkSize must be > 0');
}
if ($this->chunkOverlap < 0) {
throw new \InvalidArgumentException('chunkOverlap must be >= 0');
}
if ($this->chunkOverlap >= $this->chunkSize) {
throw new \InvalidArgumentException('chunkOverlap must be < chunkSize');
}
if ($this->embeddingDimension <= 0) {
throw new \InvalidArgumentException('embeddingDimension must be > 0');
}
if ($this->scoringVersion <= 0) {
throw new \InvalidArgumentException('scoringVersion must be > 0');
}
if ($this->indexFormat !== 'ndjson') {
throw new \InvalidArgumentException('indexFormat must be "ndjson"');
}
}
public function getChunkSize(): int
{
return $this->chunkSize;
}
public function getChunkOverlap(): int
{
return $this->chunkOverlap;
}
public function getEmbeddingModel(): string
{
return $this->embeddingModel;
}
public function getEmbeddingDimension(): int
{
return $this->embeddingDimension;
}
public function getScoringVersion(): int
{
return $this->scoringVersion;
}
public function getIndexFormat(): string
{
return $this->indexFormat;
}
public function getVectorBackend(): string
{
return $this->vectorBackend;
}
/**
* Canonical representation: nur strukturelle Felder (ohne created_at, index_version).
*/
public function toStructureArray(): array
{
return [
'embedding_model' => $this->embeddingModel,
'embedding_dimension' => $this->embeddingDimension,
'chunk_size' => $this->chunkSize,
'chunk_overlap' => $this->chunkOverlap,
'scoring_version' => $this->scoringVersion,
'index_format' => $this->indexFormat,
'vector_backend' => $this->vectorBackend,
];
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Index;
final class IndexMetaManager
{
private string $metaPath;
public function __construct(
string $projectDir,
private readonly IndexConfiguration $config,
string $relativeMetaPath = '/var/knowledge/index_meta.json'
)
{
$this->metaPath = rtrim($projectDir, '/') . $relativeMetaPath;
}
public function getMetaPath(): string
{
return $this->metaPath;
}
/**
* Gibt null zurück, wenn noch kein Meta existiert (frisches System).
*
* @return array<string,mixed>|null
*/
public function readMeta(): ?array
{
if (!is_file($this->metaPath)) {
return null;
}
$raw = file_get_contents($this->metaPath);
if ($raw === false) {
throw new \RuntimeException('Unable to read index_meta.json');
}
$data = json_decode($raw, true);
if (!is_array($data)) {
throw new \RuntimeException('index_meta.json is invalid JSON');
}
return $data;
}
/**
* Erstellt Meta, falls nicht vorhanden (z. B. nach erstem Global Reindex).
* Überschreibt NICHT automatisch, wenn vorhanden.
*
* @return array<string,mixed>
*/
public function createInitialMetaIfMissing(): array
{
$existing = $this->readMeta();
if ($existing !== null) {
return $existing;
}
$meta = $this->buildMetaPayload(indexVersion: 1);
$this->atomicWriteJson($meta);
return $meta;
}
/**
* Guardrail: Prüft, ob die aktuelle Config kompatibel zur gespeicherten Meta ist.
* Wenn nicht: IndexStructureChangedException -> Global Reindex erzwingen.
*/
public function validateAgainstCurrent(): void
{
$meta = $this->readMeta();
// Wenn noch kein Meta existiert, lassen wir lokale Ingests NICHT einfach laufen.
// Governance: Erst Global Reindex erzeugt Meta sauber.
if ($meta === null) {
throw new IndexStructureChangedException(
'index_meta.json missing. Please run a Global Reindex to initialize index structure metadata.',
['reason' => 'missing_meta']
);
}
$expected = $this->config->toStructureArray();
$diff = $this->diffStructure($meta, $expected);
if ($diff !== []) {
throw new IndexStructureChangedException(
'Index structure changed. Global Reindex required.',
$diff
);
}
}
/**
* Wird beim Global Reindex verwendet:
* - index_version++ (oder initialisieren)
* - Meta atomar schreiben
*
* @return array<string,mixed> new meta
*/
public function writeMetaForGlobalReindex(): array
{
$current = $this->readMeta();
$nextVersion = 1;
if (is_array($current) && isset($current['index_version']) && is_int($current['index_version'])) {
$nextVersion = $current['index_version'] + 1;
}
$meta = $this->buildMetaPayload($nextVersion);
$this->atomicWriteJson($meta);
return $meta;
}
public function getConfig(): IndexConfiguration
{
return $this->config;
}
// -------------------------
// Internals
// -------------------------
/**
* @return array<string,mixed>
*/
private function buildMetaPayload(int $indexVersion): array
{
$structure = $this->config->toStructureArray();
return [
'index_version' => $indexVersion,
'created_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
'embedding_model' => $structure['embedding_model'],
'embedding_dimension' => $structure['embedding_dimension'],
'chunk_size' => $structure['chunk_size'],
'chunk_overlap' => $structure['chunk_overlap'],
'scoring_version' => $structure['scoring_version'],
'index_format' => $structure['index_format'],
'vector_backend' => $structure['vector_backend'],
];
}
/**
* @param array<string,mixed> $meta
* @param array<string,mixed> $expected
* @return array<string,mixed> diff
*/
private function diffStructure(array $meta, array $expected): array
{
$diff = [];
foreach ($expected as $key => $value) {
$actual = $meta[$key] ?? null;
if ($actual !== $value) {
$diff[$key] = [
'expected' => $value,
'actual' => $actual,
];
}
}
// index_format ist zwingend
if (($meta['index_format'] ?? null) !== 'ndjson') {
$diff['index_format'] = [
'expected' => 'ndjson',
'actual' => $meta['index_format'] ?? null,
];
}
return $diff;
}
/**
* @param array<string,mixed> $payload
*/
private function atomicWriteJson(array $payload): void
{
$dir = \dirname($this->metaPath);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Unable to create directory: ' . $dir);
}
$tmp = $this->metaPath . '.tmp';
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new \RuntimeException('Unable to encode index_meta.json');
}
if (file_put_contents($tmp, $json . PHP_EOL) === false) {
throw new \RuntimeException('Unable to write temp meta file');
}
// atomarer Switch
if (!rename($tmp, $this->metaPath)) {
@unlink($tmp);
throw new \RuntimeException('Unable to switch meta file atomically');
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Index;
/**
* Wird geworfen, wenn lokale Ingests nicht mehr kompatibel sind
* und ein Global Reindex erzwungen werden muss.
*/
final class IndexStructureChangedException extends \RuntimeException
{
/**
* @param array<string,mixed> $diff
*/
public function __construct(
string $message,
private readonly array $diff = [],
int $code = 0,
?\Throwable $previous = null
)
{
parent::__construct($message, $code, $previous);
}
/**
* @return array<string,mixed>
*/
public function getDiff(): array
{
return $this->diff;
}
}

182
src/Ingest/IngestFlow.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Ingest;
use App\Entity\DocumentVersion;
use App\Entity\IngestJob;
use App\Entity\User;
use App\Index\IndexMetaManager;
use App\Index\IndexStructureChangedException;
use App\Knowledge\ChunkManager;
use App\Service\IngestJobService;
use App\Service\LockService;
use App\Knowledge\Ingest\KnowledgeIngestService;
use App\Vector\VectorIndexBuilder;
use Doctrine\ORM\EntityManagerInterface;
final class IngestFlow
{
public function __construct(
private readonly LockService $lockService,
private readonly IngestJobService $jobService,
private readonly KnowledgeIngestService $knowledgeIngestService,
private readonly ChunkManager $chunkManager,
private readonly VectorIndexBuilder $vectorBuilder,
private readonly IndexMetaManager $metaManager,
private readonly EntityManagerInterface $em,
) {
}
// ============================================================
// LOCAL DOCUMENT INGEST
// ============================================================
public function ingestDocumentVersion(
DocumentVersion $version,
User $user
): IngestJob {
if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.');
}
$job = null;
try {
$job = $this->jobService->startJob(
IngestJob::TYPE_DOCUMENT,
$user,
$version->getDocument()->getId(),
$version->getId(),
);
$version->setIngestStatus(DocumentVersion::INGEST_RUNNING);
$this->em->flush();
// --------------------------------------------------
// Guardrail: Struktur prüfen
// --------------------------------------------------
$this->metaManager->validateAgainstCurrent();
// --------------------------------------------------
// Alte Chunks dieses Dokuments entfernen (Streaming)
// --------------------------------------------------
$this->chunkManager->compactByDocument(
$version->getDocument()->getId()
);
// --------------------------------------------------
// Neue Chunks erzeugen
// --------------------------------------------------
$records = $this->knowledgeIngestService
->buildChunkRecords($version);
// --------------------------------------------------
// Append in NDJSON
// --------------------------------------------------
$this->chunkManager->appendChunks($records);
// --------------------------------------------------
// FAISS komplett neu bauen (deterministisch)
// --------------------------------------------------
$logPath = $job->getLogPath();
$this->vectorBuilder->rebuildFromNdjson($logPath);
// --------------------------------------------------
// Erfolg
// --------------------------------------------------
$version->setIngestStatus(DocumentVersion::INGEST_INDEXED);
$this->jobService->markCompleted($job);
$this->em->flush();
} catch (IndexStructureChangedException $e) {
if ($job) {
$this->jobService->markFailed($job, $e->getMessage());
}
$version->setIngestStatus(DocumentVersion::INGEST_FAILED);
$this->em->flush();
throw $e;
} catch (\Throwable $e) {
if ($job) {
$this->jobService->markFailed($job, $e->getMessage());
}
$version->setIngestStatus(DocumentVersion::INGEST_FAILED);
$this->em->flush();
throw $e;
} finally {
$this->lockService->release();
}
return $job;
}
// ============================================================
// GLOBAL REINDEX
// ============================================================
public function globalReindex(User $user): IngestJob
{
if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.');
}
$job = null;
try {
$job = $this->jobService->startJob(
IngestJob::TYPE_GLOBAL_REINDEX,
$user
);
// --------------------------------------------------
// Alle aktiven Dokumente neu ingestieren
// --------------------------------------------------
$allRecords = $this->knowledgeIngestService
->buildAllActiveChunkRecords();
// --------------------------------------------------
// Komplettes NDJSON neu schreiben
// --------------------------------------------------
$this->chunkManager->rewriteAll($allRecords);
// --------------------------------------------------
// FAISS komplett neu bauen
// --------------------------------------------------
$logPath = $job->getLogPath();
$this->vectorBuilder->rebuildFromNdjson($logPath);
// --------------------------------------------------
// Meta aktualisieren + index_version++
// --------------------------------------------------
$this->metaManager->writeMetaForGlobalReindex();
$this->jobService->markCompleted($job);
} catch (\Throwable $e) {
if ($job) {
$this->jobService->markFailed($job, $e->getMessage());
}
throw $e;
} finally {
$this->lockService->release();
}
return $job;
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Knowledge;
use Symfony\Component\Uid\Uuid;
final class ChunkManager
{
private string $indexPath;
public function __construct(
string $projectDir,
string $relativeIndexPath = '/var/knowledge/index.ndjson'
) {
$this->indexPath = rtrim($projectDir, '/') . $relativeIndexPath;
}
public function getIndexPath(): string
{
return $this->indexPath;
}
// ============================================================
// APPEND
// ============================================================
/**
* @param iterable<array<string,mixed>> $records
*/
public function appendChunks(iterable $records): void
{
$dir = \dirname($this->indexPath);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Unable to create index directory');
}
$handle = fopen($this->indexPath, 'ab');
if (!$handle) {
throw new \RuntimeException('Unable to open index.ndjson for append');
}
foreach ($records as $record) {
$json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
fclose($handle);
throw new \RuntimeException('Unable to encode chunk record');
}
fwrite($handle, $json . PHP_EOL);
}
fclose($handle);
}
// ============================================================
// COMPACTION Entfernt alle Chunks eines Dokuments
// ============================================================
public function compactByDocument(Uuid $documentId): void
{
if (!is_file($this->indexPath)) {
return; // nichts zu kompaktieren
}
$tmpPath = $this->indexPath . '.tmp';
$in = fopen($this->indexPath, 'rb');
$out = fopen($tmpPath, 'wb');
if (!$in || !$out) {
throw new \RuntimeException('Unable to open index for compaction');
}
$docIdString = $documentId->toRfc4122();
while (($line = fgets($in)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$data = json_decode($line, true);
if (!is_array($data)) {
continue; // skip corrupted line
}
if (($data['document_id'] ?? null) === $docIdString) {
continue; // skip this document's chunks
}
fwrite($out, $line . PHP_EOL);
}
fclose($in);
fclose($out);
$this->atomicSwitch($tmpPath, $this->indexPath);
}
// ============================================================
// FULL REWRITE (Global Reindex)
// ============================================================
/**
* @param iterable<array<string,mixed>> $records
*/
public function rewriteAll(iterable $records): void
{
$dir = \dirname($this->indexPath);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Unable to create index directory');
}
$tmpPath = $this->indexPath . '.tmp';
$handle = fopen($tmpPath, 'wb');
if (!$handle) {
throw new \RuntimeException('Unable to open temp index file');
}
foreach ($records as $record) {
$json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
fclose($handle);
throw new \RuntimeException('Unable to encode chunk record');
}
fwrite($handle, $json . PHP_EOL);
}
fclose($handle);
$this->atomicSwitch($tmpPath, $this->indexPath);
}
// ============================================================
// STREAM READ (für FAISS rebuild)
// ============================================================
/**
* @return \Generator<array<string,mixed>>
*/
public function streamAll(): \Generator
{
if (!is_file($this->indexPath)) {
return;
}
$handle = fopen($this->indexPath, 'rb');
if (!$handle) {
throw new \RuntimeException('Unable to open index.ndjson for read');
}
try {
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$data = json_decode($line, true);
if (is_array($data)) {
yield $data;
}
}
} finally {
fclose($handle);
}
}
// ============================================================
// INTERNAL ATOMIC SWITCH
// ============================================================
private function atomicSwitch(string $tmp, string $final): void
{
if (!rename($tmp, $final)) {
@unlink($tmp);
throw new \RuntimeException('Atomic switch failed for index.ndjson');
}
}
}

View File

@@ -1,39 +1,93 @@
<?php <?php
// src/Knowledge/Ingest/KnowledgeIngestService.php
declare(strict_types=1); declare(strict_types=1);
namespace App\Knowledge\Ingest; namespace App\Knowledge\Ingest;
use App\Entity\DocumentVersion;
use App\Repository\DocumentVersionRepository;
use Symfony\Component\Uid\Uuid;
final class KnowledgeIngestService final class KnowledgeIngestService
{ {
public function __construct( public function __construct(
private DocumentLoader $loader, private DocumentLoader $loader,
private SimpleChunker $chunker, private SimpleChunker $chunker,
private ChunkWriter $writer, private DocumentVersionRepository $versionRepo,
private ChunkIndexWriter $indexWriter, ) {
)
{
} }
/** @return string[] written chunk filenames */ /**
public function ingestFile(string $path, bool $optimize = false): array * Lokaler Ingest: erzeugt NDJSON-Records für genau diese Version.
*
* @return iterable<array<string,mixed>>
*/
public function buildChunkRecords(DocumentVersion $version): iterable
{ {
$text = $this->loader->load($path); $text = $this->loader->load($version->getFilePath());
$text = $this->optimizeText($text);
if ($optimize) {
$text = preg_replace("/\n{3,}/", "\n\n", $text);
$text = preg_replace("/[ \t]+$/m", "", $text);
}
$sourceHash = sha1($text);
$sourceName = basename($path);
if ($this->indexWriter->hasSourceHash($sourceName, $sourceHash)) {
return [];
}
$chunks = $this->chunker->chunk($text); $chunks = $this->chunker->chunk($text);
return $this->writer->write($sourceName, $chunks, $sourceHash);
$documentId = $version->getDocument()->getId()->toRfc4122();
$versionId = $version->getId()->toRfc4122();
$index = 0;
foreach ($chunks as $chunkText) {
yield [
'chunk_id' => Uuid::v4()->toRfc4122(),
'document_id' => $documentId,
'version_id' => $versionId,
'chunk_index' => $index++,
'text' => $chunkText,
'checksum' => sha1($chunkText),
'metadata' => $this->buildMetadata($version),
];
}
}
/**
* Global Reindex: iteriert streamingfähig über alle aktiven Versionen.
* Keine RAM-Explosion, da alles generatorbasiert bleibt.
*
* @return iterable<array<string,mixed>>
*/
public function buildAllActiveChunkRecords(): iterable
{
foreach ($this->versionRepo->iterateActiveVersions() as $version) {
// yield from hält das Ganze streamingfähig (Generator-Kaskade)
yield from $this->buildChunkRecords($version);
}
}
private function optimizeText(string $text): string
{
$text = preg_replace("/\n{3,}/", "\n\n", $text);
$text = preg_replace("/[ \t]+$/m", "", $text);
return $text;
}
/**
* @return array<string,mixed>
*/
private function buildMetadata(DocumentVersion $version): array
{
$doc = $version->getDocument();
// Optional: Titel/Name, falls vorhanden
$title = null;
if (method_exists($doc, 'getTitle')) {
$title = $doc->getTitle();
} elseif (method_exists($doc, 'getName')) {
$title = $doc->getName();
}
return array_filter([
'document_title' => $title,
'version_number' => method_exists($version, 'getVersionNumber') ? $version->getVersionNumber() : null,
'file_path' => $version->getFilePath(),
], static fn($v) => $v !== null && $v !== '');
} }
} }

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\DocumentVersion;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
final class DocumentVersionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DocumentVersion::class);
}
/**
* Streamingfähige Iteration über alle aktiven Versionen (für Global Reindex).
*
* @return iterable<DocumentVersion>
*/
public function iterateActiveVersions(): iterable
{
$qb = $this->createQueryBuilder('v')
->andWhere('v.isActive = :active')
->setParameter('active', true)
->orderBy('v.createdAt', 'ASC');
return $qb->getQuery()->toIterable();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
// /**
// * @return User[] Returns an array of User objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('u.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?User
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Service;
use App\Entity\Document;
use App\Entity\DocumentVersion;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
class DocumentService
{
public function __construct(
private EntityManagerInterface $em
) {}
/**
* Erstellt ein neues Dokument inkl. Version 1
*/
public function createDocument(
string $title,
string $filePath,
User $user
): Document {
$document = new Document();
$document->setTitle($title);
$document->setCreatedBy($user);
$version = new DocumentVersion();
$version->setVersionNumber(1);
$version->setFilePath($filePath);
$version->setChecksum($this->calculateChecksum($filePath));
$version->setCreatedBy($user);
$version->setActive(true);
$document->addVersion($version);
$document->setCurrentVersion($version);
$this->em->persist($document);
$this->em->persist($version);
$this->em->flush();
return $document;
}
/**
* Fügt neue Version hinzu (immutable)
*/
public function addVersion(
Document $document,
string $filePath,
User $user
): DocumentVersion {
$nextVersionNumber = $this->getNextVersionNumber($document);
$version = new DocumentVersion();
$version->setVersionNumber($nextVersionNumber);
$version->setFilePath($filePath);
$version->setChecksum($this->calculateChecksum($filePath));
$version->setCreatedBy($user);
$version->setActive(false);
$document->addVersion($version);
$this->em->persist($version);
$this->em->flush();
return $version;
}
/**
* Aktiviert eine Version (setzt andere inaktiv)
*/
public function activateVersion(DocumentVersion $version): void
{
$document = $version->getDocument();
foreach ($document->getVersions() as $existingVersion) {
$existingVersion->setActive(false);
}
$version->setActive(true);
$document->setCurrentVersion($version);
$this->em->flush();
}
/**
* Archiviert Dokument
*/
public function archive(Document $document): void
{
$document->archive();
$this->em->flush();
}
/**
* Berechnet SHA256 Checksum
*/
private function calculateChecksum(string $filePath): string
{
if (!file_exists($filePath)) {
throw new \RuntimeException('File not found for checksum.');
}
return hash_file('sha256', $filePath);
}
/**
* Ermittelt nächste Versionsnummer
*/
private function getNextVersionNumber(Document $document): int
{
$max = 0;
foreach ($document->getVersions() as $version) {
$max = max($max, $version->getVersionNumber());
}
return $max + 1;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Service;
use App\Entity\IngestJob;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;
final class IngestJobService
{
public function __construct(private EntityManagerInterface $em)
{
}
public function startJob(
string $type,
?User $user = null,
?Uuid $documentId = null,
?Uuid $documentVersionId = null,
?string $logPath = null
): IngestJob
{
$job = new IngestJob($type);
$job->setStartedBy($user);
$job->setDocumentId($documentId);
$job->setDocumentVersionId($documentVersionId);
$job->setLogPath($logPath);
$this->em->persist($job);
$this->em->flush();
return $job;
}
public function markCompleted(IngestJob $job): void
{
$job->markCompleted();
$this->em->flush();
}
public function markFailed(IngestJob $job, string $message): void
{
$job->markFailed($message);
$this->em->flush();
}
public function markAborted(IngestJob $job): void
{
$job->markAborted();
$this->em->flush();
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Service;
use App\Entity\DocumentVersion;
use App\Entity\IngestJob;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
class IngestOrchestrator
{
public function __construct(
private LockService $lockService,
private IngestJobService $jobService,
private EntityManagerInterface $em,
) {}
/**
* Startet Ingest für eine bestimmte DocumentVersion
*/
public function runForVersion(
DocumentVersion $version,
User $user,
bool $dryRun = false
): IngestJob {
if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.');
}
$job = null;
try {
// --------------------------------------
// Job anlegen
// --------------------------------------
$job = $this->jobService->startJob(
IngestJob::TYPE_DOCUMENT,
$user,
$version->getDocument()->getId(),
$version->getId(),
);
// --------------------------------------
// Version Status RUNNING
// --------------------------------------
$version->setIngestStatus(DocumentVersion::INGEST_RUNNING);
$this->em->flush();
// --------------------------------------
// Simulierter Ablauf (noch kein echter Ingest)
// --------------------------------------
if ($dryRun) {
usleep(200000);
} else {
// Später:
// - KnowledgeIngestService
// - ChunkWriter
// - VectorIngestCommand
}
// --------------------------------------
// Erfolg
// --------------------------------------
$version->setIngestStatus(DocumentVersion::INGEST_INDEXED);
$this->jobService->markCompleted($job);
$this->em->flush();
} catch (\Throwable $e) {
if ($job) {
$this->jobService->markFailed($job, $e->getMessage());
}
$version->setIngestStatus(DocumentVersion::INGEST_FAILED);
$this->em->flush();
throw $e;
} finally {
$this->lockService->release();
}
return $job;
}
/**
* Globaler Reindex
*/
public function runGlobal(User $user, bool $dryRun = false): IngestJob
{
if (!$this->lockService->acquire()) {
throw new \RuntimeException('Another ingest job is already running.');
}
$job = null;
try {
$job = $this->jobService->startJob(
IngestJob::TYPE_GLOBAL_REINDEX,
$user
);
if ($dryRun) {
usleep(200000);
} else {
// Später:
// - Alle aktiven Dokumente neu ingestieren
// - Global vector rebuild
}
$this->jobService->markCompleted($job);
} catch (\Throwable $e) {
if ($job) {
$this->jobService->markFailed($job, $e->getMessage());
}
throw $e;
} finally {
$this->lockService->release();
}
return $job;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Service;
final class LockService
{
private $handle = null;
private string $lockFile;
public function __construct(string $projectDir)
{
$dir = $projectDir . '/var/locks';
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$this->lockFile = $dir . '/ingest.lock';
}
/**
* Gibt true zurück, wenn Lock erfolgreich gesetzt wurde.
* Wenn false: ein anderer Prozess hält den Lock.
*/
public function acquire(): bool
{
$handle = fopen($this->lockFile, 'c+');
if (!$handle) {
throw new \RuntimeException('Could not open lock file.');
}
// Nicht-blockierend: sofort true/false
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return false;
}
// Lock halten: Handle offen lassen
$this->handle = $handle;
// Optional: Metainfo reinschreiben
ftruncate($this->handle, 0);
fwrite($this->handle, (string) time());
return true;
}
public function release(): void
{
if ($this->handle) {
flock($this->handle, LOCK_UN);
fclose($this->handle);
$this->handle = null;
}
}
public function isLocked(): bool
{
$handle = fopen($this->lockFile, 'c+');
if (!$handle) {
return false;
}
$locked = !flock($handle, LOCK_EX | LOCK_NB);
if (!$locked) {
flock($handle, LOCK_UN);
}
fclose($handle);
return $locked;
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Vector;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
final class VectorIndexBuilder
{
private string $pythonBin;
private string $scriptPath;
private string $indexNdjsonPath;
private string $vectorIndexPath;
private int $timeoutSeconds;
public function __construct(
string $projectDir,
string $pythonBin = 'python3',
string $relativeScriptPath = '/vector/vector_ingest.py',
string $relativeIndexNdjsonPath = '/var/knowledge/index.ndjson',
string $relativeVectorIndexPath = '/var/knowledge/vector.index',
int $timeoutSeconds = 600
)
{
$base = rtrim($projectDir, '/');
$this->pythonBin = $pythonBin;
$this->scriptPath = $base . $relativeScriptPath;
$this->indexNdjsonPath = $base . $relativeIndexNdjsonPath;
$this->vectorIndexPath = $base . $relativeVectorIndexPath;
$this->timeoutSeconds = $timeoutSeconds;
}
public function getIndexNdjsonPath(): string
{
return $this->indexNdjsonPath;
}
public function getVectorIndexPath(): string
{
return $this->vectorIndexPath;
}
public function getScriptPath(): string
{
return $this->scriptPath;
}
/**
* Rebuild FAISS Index deterministisch aus index.ndjson.
*
* Erwartung: Python schreibt in $tmpVectorIndexPath, wir schalten atomar um.
*
* @param string|null $logPath Optional: stdout/stderr dorthin appenden
*/
public function rebuildFromNdjson(?string $logPath = null): void
{
if (!is_file($this->scriptPath)) {
throw new \RuntimeException('vector_ingest.py not found at: ' . $this->scriptPath);
}
if (!is_file($this->indexNdjsonPath)) {
throw new \RuntimeException('index.ndjson not found at: ' . $this->indexNdjsonPath);
}
$dir = \dirname($this->vectorIndexPath);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
throw new \RuntimeException('Unable to create vector index directory: ' . $dir);
}
$tmpVectorIndexPath = $this->vectorIndexPath . '.tmp';
// Vorheriges tmp entfernen (Sicherheit)
if (is_file($tmpVectorIndexPath)) {
@unlink($tmpVectorIndexPath);
}
// ----------------------------
// Python-Aufruf (konservativ)
// ----------------------------
// Wir erwarten/standardisieren (ab jetzt) CLI-Args:
// --index <path-to-index.ndjson>
// --out <path-to-vector.index.tmp>
//
// Falls dein Python-Script aktuell andere Args hat,
// passen wir es im nächsten Schritt konsistent an.
$cmd = [
$this->pythonBin,
$this->scriptPath,
'--index', $this->indexNdjsonPath,
'--out', $tmpVectorIndexPath,
];
$process = new Process($cmd);
$process->setTimeout($this->timeoutSeconds);
$this->runProcess($process, $logPath);
// Python muss tmp erzeugt haben
if (!is_file($tmpVectorIndexPath) || filesize($tmpVectorIndexPath) === 0) {
throw new \RuntimeException('Vector index rebuild failed: tmp output missing or empty: ' . $tmpVectorIndexPath);
}
// Atomarer Switch
$this->atomicSwitch($tmpVectorIndexPath, $this->vectorIndexPath);
}
// -------------------------
// Internals
// -------------------------
private function runProcess(Process $process, ?string $logPath): void
{
if ($logPath !== null) {
$this->appendLog($logPath, "\n=== VectorIndexBuilder START " . (new \DateTimeImmutable())->format(DATE_ATOM) . " ===\n");
$this->appendLog($logPath, "CMD: " . $process->getCommandLine() . "\n");
}
$process->run(function (string $type, string $buffer) use ($logPath) {
if ($logPath === null) {
return;
}
// TYPE: Process::OUT / Process::ERR
$this->appendLog($logPath, $buffer);
});
if (!$process->isSuccessful()) {
if ($logPath !== null) {
$this->appendLog($logPath, "\n=== VectorIndexBuilder FAILED ===\n");
$this->appendLog($logPath, "ExitCode: " . $process->getExitCode() . "\n");
$this->appendLog($logPath, "STDERR:\n" . $process->getErrorOutput() . "\n");
}
throw new ProcessFailedException($process);
}
if ($logPath !== null) {
$this->appendLog($logPath, "\n=== VectorIndexBuilder OK " . (new \DateTimeImmutable())->format(DATE_ATOM) . " ===\n");
}
}
private function appendLog(string $logPath, string $content): void
{
$dir = \dirname($logPath);
if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
// Wenn Log nicht möglich ist: nicht hart scheitern (Build ist wichtiger)
return;
}
@file_put_contents($logPath, $content, FILE_APPEND);
}
private function atomicSwitch(string $tmp, string $final): void
{
if (!rename($tmp, $final)) {
@unlink($tmp);
throw new \RuntimeException('Atomic switch failed for vector.index');
}
}
}

View File

@@ -2,88 +2,125 @@
import sys import sys
import json import json
import argparse
from pathlib import Path from pathlib import Path
# --------------------------------------------------------- # ---------------------------------------------------------
# Argument handling # Argument parsing
# --------------------------------------------------------- # ---------------------------------------------------------
if len(sys.argv) < 3: parser = argparse.ArgumentParser(description="Build FAISS index from NDJSON")
print("ERROR: Missing arguments (vectorDir, knowledgeDir)")
sys.exit(2)
vector_dir = Path(sys.argv[1]).resolve() parser.add_argument("--index", required=True, help="Path to index.ndjson")
knowledge_dir = Path(sys.argv[2]).resolve() parser.add_argument("--out", required=True, help="Path to output vector.index")
parser.add_argument("--model", default="all-MiniLM-L6-v2", help="SentenceTransformer model")
index_json = knowledge_dir / "index.json" args = parser.parse_args()
index_out = vector_dir / "vector.index"
meta_out = vector_dir / "vector_meta.json" index_path = Path(args.index).resolve()
out_path = Path(args.out).resolve()
# --------------------------------------------------------- # ---------------------------------------------------------
# Dependency checks # Dependency checks
# --------------------------------------------------------- # ---------------------------------------------------------
try: try:
import faiss # noqa import faiss
except Exception: except Exception:
print("ERROR: Python module 'faiss' not found.") print("ERROR: Python module 'faiss' not found.")
sys.exit(10) sys.exit(10)
try: try:
from sentence_transformers import SentenceTransformer # noqa from sentence_transformers import SentenceTransformer
except Exception: except Exception:
print("ERROR: Python module 'sentence-transformers' not found.") print("ERROR: Python module 'sentence-transformers' not found.")
sys.exit(11) sys.exit(11)
import numpy as np
import faiss import faiss
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
# --------------------------------------------------------- # ---------------------------------------------------------
# File checks # File checks
# --------------------------------------------------------- # ---------------------------------------------------------
if not index_json.is_file(): if not index_path.is_file():
print(f"ERROR: index.json not found at {index_json}") print(f"ERROR: index.ndjson not found at {index_path}")
sys.exit(20) sys.exit(20)
# --------------------------------------------------------- # ---------------------------------------------------------
# Load chunks from index.json # Load model
# --------------------------------------------------------- # ---------------------------------------------------------
with open(index_json, "r", encoding="utf-8") as f: print(f"Loading embedding model: {args.model}")
data = json.load(f) model = SentenceTransformer(args.model)
# ---------------------------------------------------------
# Streaming read NDJSON
# ---------------------------------------------------------
texts = [] texts = []
ids = [] ids = []
for entry in data: print("Reading NDJSON...")
if "file" not in entry:
continue
chunk_path = knowledge_dir / "chunks" / entry["file"] with open(index_path, "r", encoding="utf-8") as f:
if not chunk_path.is_file(): for line in f:
continue line = line.strip()
if not line:
continue
text = chunk_path.read_text(encoding="utf-8").strip() try:
if not text: entry = json.loads(line)
continue except Exception:
continue
texts.append(text) text = entry.get("text")
ids.append(entry["file"]) chunk_id = entry.get("chunk_id")
if not text or not chunk_id:
continue
texts.append(text)
ids.append(chunk_id)
if not texts: if not texts:
print("ERROR: No chunks loaded from index.json") print("ERROR: No valid chunks found in index.ndjson")
sys.exit(21) sys.exit(21)
print(f"Loaded {len(texts)} chunks.")
# --------------------------------------------------------- # ---------------------------------------------------------
# Build vector index # Build embeddings
# --------------------------------------------------------- # ---------------------------------------------------------
model = SentenceTransformer("all-MiniLM-L6-v2") print("Encoding embeddings...")
embeddings = model.encode(texts, normalize_embeddings=True) embeddings = model.encode(
texts,
normalize_embeddings=True,
show_progress_bar=True,
batch_size=64
)
embeddings = np.array(embeddings).astype("float32")
dim = embeddings.shape[1] dim = embeddings.shape[1]
print(f"Embedding dimension: {dim}")
# ---------------------------------------------------------
# Build FAISS index
# ---------------------------------------------------------
print("Building FAISS index...")
index = faiss.IndexFlatIP(dim) index = faiss.IndexFlatIP(dim)
index.add(embeddings) index.add(embeddings)
faiss.write_index(index, str(index_out)) # Ensure output directory exists
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(meta_out, "w", encoding="utf-8") as f: print(f"Writing FAISS index to {out_path}")
faiss.write_index(index, str(out_path))
# ---------------------------------------------------------
# Write ID mapping meta
# ---------------------------------------------------------
meta_path = out_path.with_suffix(".meta.json")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(ids, f) json.dump(ids, f)
print(f"Indexed {len(ids)} chunks.") print(f"Indexed {len(ids)} chunks successfully.")
sys.exit(0)

View File

@@ -1,4 +1,40 @@
{ {
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.18",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"symfony/console": { "symfony/console": {
"version": "5.4", "version": "5.4",
"recipe": { "recipe": {
@@ -42,6 +78,15 @@
"src/Kernel.php" "src/Kernel.php"
] ]
}, },
"symfony/maker-bundle": {
"version": "1.66",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/monolog-bundle": { "symfony/monolog-bundle": {
"version": "4.0", "version": "4.0",
"recipe": { "recipe": {
@@ -54,6 +99,18 @@
"config/packages/monolog.yaml" "config/packages/monolog.yaml"
] ]
}, },
"symfony/property-info": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": { "symfony/routing": {
"version": "5.4", "version": "5.4",
"recipe": { "recipe": {
@@ -67,6 +124,32 @@
"config/routes.yaml" "config/routes.yaml"
] ]
}, },
"symfony/security-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": { "symfony/uid": {
"version": "7.4", "version": "7.4",
"recipe": { "recipe": {

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock %}</title>
<link href="/assets/styles/bootstrap.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="/assets/styles/base.css">
</head>
<body class="bg-dark text-light">
<nav class="navbar navbar-dark bg-black text-info px-3">
<span class="navbar-brand">mitho Admin</span>
<div class="ms-auto d-flex align-items-center gap-3">
{% if app.user %}
<span class="small text-secondary">{{ app.user.userIdentifier }}</span>
<a class="btn btn-sm btn-outline-light" href="{{ path('admin_logout') }}">Logout</a>
{% endif %}
</div>
</nav>
<div class="container-fluid">
<div class="row">
<div class="col-2 bg-black text-info border-end border-secondary min-vh-100 pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_dashboard') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_documents') }}">Dokumente</a>
</li>
<li class="nav-item">
<a class="nav-link text-light" href="{{ path('admin_jobs') }}">
Ingest Jobs
</a>
</li>
</ul>
</div>
<div class="col-10 pt-3">
{% block body %}{% endblock %}
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Admin Dashboard{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Dashboard</h1>
<div class="card bg-black text-info border-secondary">
<div class="card-body">
<div class="mb-2">
<strong>User:</strong> {{ app.user.userIdentifier }}
</div>
<div class="mb-2">
<strong>Rollen:</strong> {{ app.user.roles|join(', ') }}
</div>
<hr class="border-secondary">
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Dokumente{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Dokumente</h1>
<a href="{{ path('admin_document_new') }}" class="btn btn-sm btn-light">
+ Neues Dokument
</a>
</div>
{% if documents is empty %}
<div class="alert alert-secondary">
Keine Dokumente vorhanden.
</div>
{% else %}
<div class="card bg-black text-info border-secondary">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Titel</th>
<th>Status</th>
<th>Versionen</th>
<th>Aktive Version</th>
<th>Erstellt am</th>
</tr>
</thead>
<tbody>
{% for document in documents %}
<tr>
<td>
<a href="{{ path('admin_document_show', {id: document.id}) }}" class="text-decoration-none text-light">
{{ document.title }}
</a>
</td>
<td>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</td>
<td>{{ document.versions|length }}</td>
<td>
{% if document.currentVersion %}
v{{ document.currentVersion.versionNumber }}
{% else %}
-
{% endif %}
</td>
<td>{{ document.createdAt|date('d.m.Y H:i') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Neues Dokument{% endblock %}
{% block body %}
<h1 class="h4 mb-4">Neues Dokument</h1>
<form method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Titel</label>
<input class="form-control" name="title" required>
</div>
<div class="mb-3">
<label class="form-label">Datei</label>
<input type="file" class="form-control" name="file" required>
</div>
<button class="btn btn-light">Speichern</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Neue Version{% endblock %}
{% block body %}
<a href="{{ path('admin_document_show', {id: document.id}) }}"
class="btn btn-sm btn-outline-light mb-3">
← Zurück
</a>
<h1 class="h4 mb-4">
Neue Version für: {{ document.title }}
</h1>
<form method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" name="file" required>
</div>
<button class="btn btn-light">
Version hochladen
</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Dokument{% endblock %}
{% block body %}
<a href="{{ path('admin_documents') }}" class="btn btn-sm btn-outline-light mb-3">
← Zurück
</a>
<h1 class="h4 mb-3">{{ document.title }}</h1>
<div class="card bg-black text-info border-secondary mb-4">
<div class="card-body">
<div class="mb-2">
<strong>Status:</strong>
{% if document.status == 'ACTIVE' %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Archiviert</span>
{% endif %}
</div>
<div class="mb-2">
<strong>Erstellt von:</strong>
{{ document.createdBy.email }}
</div>
<div class="mb-2">
<strong>Erstellt am:</strong>
{{ document.createdAt|date('d.m.Y H:i') }}
</div>
<div class="mb-2">
<strong>Aktive Version:</strong>
{% if document.currentVersion %}
v{{ document.currentVersion.versionNumber }}
{% else %}
-
{% endif %}
</div>
</div>
</div>
<h2 class="h5 mb-3">Versionen</h2>
<a href="{{ path('admin_document_version_new', {id: document.id}) }}"
class="btn btn-sm btn-light mb-3">
+ Neue Version
</a>
{% if document.versions is empty %}
<div class="alert alert-secondary">
Keine Versionen vorhanden.
</div>
{% else %}
<div class="card bg-black text-info border-secondary">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Version</th>
<th>Aktiv</th>
<th>Ingest</th>
<th>Checksum</th>
<th>Erstellt von</th>
<th>Datum</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for version in document.versions %}
<tr>
<td>v{{ version.versionNumber }}</td>
<td>
{% if version.isActive %}
<span class="badge bg-success">Ja</span>
{% else %}
<span class="badge bg-secondary">Nein</span>
{% endif %}
</td>
<td>
{% if version.ingestStatus == 'INDEXED' %}
<span class="badge bg-success">INDEXED</span>
{% elseif version.ingestStatus == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif version.ingestStatus == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% else %}
<span class="badge bg-secondary">PENDING</span>
{% endif %}
</td>
<td>
{{ version.checksum[:10] }}...
</td>
<td>
{{ version.createdBy.email }}
</td>
<td>
{{ version.createdAt|date('d.m.Y H:i') }}
</td>
<td>
{% if version.isActive %}
{% if version.ingestStatus != constant('App\\Entity\\DocumentVersion::INGEST_RUNNING') %}
<form method="post"
action="{{ path('admin_document_version_ingest', {versionId: version.id}) }}"
style="display:inline;">
<input type="hidden" name="_token" value="{{ csrf_token('ingest_version') }}">
<button class="btn btn-sm btn-outline-info">
Ingest starten
</button>
</form>
{% else %}
<span class="text-warning">Läuft...</span>
{% endif %}
{% else %}
<span class="text-muted">Nicht aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,109 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Ingest Jobs{% endblock %}
{% block body %}
<h1 class="h4 mb-4">Ingest Jobs</h1>
<form method="post"
action="{{ path('admin_global_reindex') }}"
onsubmit="return confirm('Global Reindex starten? Dies kann einige Zeit dauern.');">
<button type="submit"
class="btn btn-danger mb-3">
Global Reindex starten
</button>
</form>
{% if jobs is empty %}
<div class="alert alert-secondary">
Keine Jobs vorhanden.
</div>
{% else %}
<div class="card bg-black text-info border-secondary">
<div class="card-body p-0">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Typ</th>
<th>Status</th>
<th>Dokument</th>
<th>Version</th>
<th>Gestartet</th>
<th>Beendet</th>
<th>User</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<a href="{{ path('admin_job_show', {id: job.id}) }}"
class="text-decoration-none text-light">
{{ job.id }}
</a>
</td>
<td>{{ job.type }}</td>
<td>
{% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% else %}
<span class="badge bg-secondary">{{ job.status }}</span>
{% endif %}
</td>
<td>
{% if job.documentId %}
{{ job.documentId }}
{% else %}
-
{% endif %}
</td>
<td>
{% if job.documentVersionId %}
{{ job.documentVersionId }}
{% else %}
-
{% endif %}
</td>
<td>{{ job.startedAt|date('d.m.Y H:i:s') }}</td>
<td>
{% if job.finishedAt %}
{{ job.finishedAt|date('d.m.Y H:i:s') }}
{% else %}
-
{% endif %}
</td>
<td>
{% if job.startedBy %}
{{ job.startedBy.email }}
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Ingest Job{% endblock %}
{% block body %}
<a href="{{ path('admin_jobs') }}"
class="btn btn-sm btn-outline-light mb-3">
← Zurück
</a>
<h1 class="h4 mb-4">Ingest Job</h1>
<div class="card bg-black text-info border-secondary">
<div class="card-body">
<div class="mb-2">
<strong>ID:</strong> {{ job.id }}
</div>
<div class="mb-2">
<strong>Typ:</strong> {{ job.type }}
</div>
<div class="mb-2">
<strong>Status:</strong>
{% if job.status == 'COMPLETED' %}
<span class="badge bg-success">COMPLETED</span>
{% elseif job.status == 'RUNNING' %}
<span class="badge bg-warning text-dark">RUNNING</span>
{% elseif job.status == 'FAILED' %}
<span class="badge bg-danger">FAILED</span>
{% else %}
<span class="badge bg-secondary">{{ job.status }}</span>
{% endif %}
</div>
<div class="mb-2">
<strong>Dokument:</strong>
{{ job.documentId ?? '-' }}
</div>
<div class="mb-2">
<strong>Version:</strong>
{{ job.documentVersionId ?? '-' }}
</div>
<div class="mb-2">
<strong>Gestartet:</strong>
{{ job.startedAt|date('d.m.Y H:i:s') }}
</div>
<div class="mb-2">
<strong>Beendet:</strong>
{% if job.finishedAt %}
{{ job.finishedAt|date('d.m.Y H:i:s') }}
{% else %}
-
{% endif %}
</div>
<div class="mb-2">
<strong>Gestartet von:</strong>
{% if job.startedBy %}
{{ job.startedBy.email }}
{% else %}
-
{% endif %}
</div>
{% if job.errorMessage %}
<div class="alert alert-danger mt-3">
<strong>Fehler:</strong><br>
{{ job.errorMessage }}
</div>
{% endif %}
{% if job.logPath %}
<div class="mt-3">
<strong>Log Datei:</strong><br>
<code>{{ job.logPath }}</code>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Admin Login{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-12 col-md-5 col-lg-4">
<h1 class="h4 mb-3">Admin Login</h1>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form method="post" action="{{ path('admin_login') }}">
<div class="mb-3">
<label class="form-label">E-Mail</label>
<input class="form-control" name="_username" value="{{ last_username }}" autocomplete="email" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input class="form-control" type="password" name="_password" autocomplete="current-password" required>
</div>
{# CSRF #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button class="btn btn-light w-100" type="submit">Einloggen</button>
</form>
</div>
</div>
{% endblock %}

16
templates/base.html.twig Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>