first commit
This commit is contained in:
36
.env
Normal file
36
.env
Normal file
@@ -0,0 +1,36 @@
|
||||
# In all environments, the following files are loaded if they exist,
|
||||
# the latter taking precedence over the former:
|
||||
#
|
||||
# * .env contains default values for the environment variables needed by the app
|
||||
# * .env.local uncommitted file with local overrides
|
||||
# * .env.$APP_ENV committed environment-specific defaults
|
||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides
|
||||
#
|
||||
# Real environment variables win over .env files.
|
||||
#
|
||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
||||
# https://symfony.com/doc/current/configuration/secrets.html
|
||||
#
|
||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=prod
|
||||
APP_SECRET=09333662211af45850ff13d68a40f8e3
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> AI Agent Core ###
|
||||
AI_LLM_API_URL=http://192.168.178.143:11434/api/generate
|
||||
AI_LLM_MODEL=mto-model
|
||||
AI_LLM_TIMEOUT=600
|
||||
|
||||
AI_HISTORY_DIR=var/agent-history
|
||||
AI_CONTEXT_LINES=20
|
||||
AI_CONTEXT_LINES_FULL=500
|
||||
###< AI Agent Core ###
|
||||
|
||||
###> AI Agent Debug ###
|
||||
AI_DEBUG=false
|
||||
AI_LOG_PROMPT=false
|
||||
AI_LOG_CONTEXT=false
|
||||
###< AI Agent Debug ###
|
||||
36
.env.local
Normal file
36
.env.local
Normal file
@@ -0,0 +1,36 @@
|
||||
APP_ENV=prod
|
||||
MAILER_DRIVER="smtp"
|
||||
MAILER_HOST="127.0.0.1"
|
||||
MAILER_URL="smtp://127.0.0.1:1025"
|
||||
DATABASE_DRIVER="mysql"
|
||||
DATABASE_HOST="db"
|
||||
DATABASE_USER="db"
|
||||
DATABASE_SERVER="mysql://db:3306"
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?sslmode=disable&charset=utf8mb4&serverVersion=10.11.0-mariadb"
|
||||
VECTOR_DATABASE_URL=postgresql://db:db@pgvector:5432/db
|
||||
MAILER_CATCHER="1"
|
||||
MAILER_WEB_URL="https://ki-agent.ddev.site:8026"
|
||||
DATABASE_PASSWORD="db"
|
||||
DATABASE_PORT="3306"
|
||||
DATABASE_VERSION="10.11.0-mariadb"
|
||||
MAILER_PASSWORD=""
|
||||
MAILER_DSN="smtp://127.0.0.1:1025"
|
||||
MAILER_PORT="1025"
|
||||
MAILER_AUTH_MODE=""
|
||||
MAILER_USERNAME=""
|
||||
DATABASE_NAME="db"
|
||||
|
||||
AI_LLM_API_URL=http://192.168.178.143:11434/api/generate
|
||||
AI_LLM_MODEL=mto-model
|
||||
AI_LLM_TIMEOUT=600
|
||||
AI_HISTORY_DIR=var/agent-history
|
||||
|
||||
|
||||
###> AI Agent Debug ###
|
||||
AI_DEBUG=false
|
||||
AI_LOG_PROMPT=true
|
||||
AI_LOG_CONTEXT=true
|
||||
###< AI Agent Debug ###
|
||||
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
.ddev
|
||||
.idea
|
||||
/var/knowledge
|
||||
/src/Vector/.venv
|
||||
/src/Vector/vector.index
|
||||
/src/Vector/vector_meta.json
|
||||
test.*
|
||||
# ---> Symfony
|
||||
# Cache and logs (Symfony2)
|
||||
/app/cache/*
|
||||
|
||||
21
bin/console
Executable file
21
bin/console
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
59
composer.json
Normal file
59
composer.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-iconv": "*",
|
||||
"symfony/console": "^7.4",
|
||||
"symfony/dotenv": "^7.4",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "^7.4",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/runtime": "^7.4",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/yaml": "^7.4"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
3136
composer.lock
generated
Normal file
3136
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
config/bundles.php
Normal file
6
config/bundles.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
];
|
||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
24
config/packages/framework.yaml
Normal file
24
config/packages/framework.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
#csrf_protection: true
|
||||
http_method_override: false
|
||||
|
||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: auto
|
||||
cookie_samesite: lax
|
||||
storage_factory_id: session.storage.factory.native
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
9
config/packages/monolog.yaml
Normal file
9
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
monolog:
|
||||
channels: ['agent']
|
||||
|
||||
handlers:
|
||||
agent:
|
||||
type: stream
|
||||
path: '%kernel.logs_dir%/agent.log'
|
||||
level: debug
|
||||
channels: ['agent']
|
||||
12
config/packages/routing.yaml
Normal file
12
config/packages/routing.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
959
config/reference.php
Normal file
959
config/reference.php
Normal file
@@ -0,0 +1,959 @@
|
||||
<?php
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the services and bundles of an application.
|
||||
*
|
||||
* Services declared with the config() method below are autowired and autoconfigured by default.
|
||||
*
|
||||
* This is for apps only. Bundles SHOULD NOT use it.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/services.php
|
||||
* namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
*
|
||||
* return App::config([
|
||||
* 'services' => [
|
||||
* 'App\\' => [
|
||||
* 'resource' => '../src/',
|
||||
* ],
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type ImportsConfig = list<string|array{
|
||||
* resource: string,
|
||||
* type?: string|null,
|
||||
* ignore_errors?: bool,
|
||||
* }>
|
||||
* @psalm-type ParametersConfig = array<string, scalar|\UnitEnum|array<scalar|\UnitEnum|array<mixed>|null>|null>
|
||||
* @psalm-type ArgumentsType = list<mixed>|array<string, mixed>
|
||||
* @psalm-type CallType = array<string, ArgumentsType>|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool}
|
||||
* @psalm-type TagsType = list<string|array<string, array<string, mixed>>> // arrays inside the list must have only one element, with the tag name as the key
|
||||
* @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator
|
||||
* @psalm-type DeprecationType = array{package: string, version: string, message?: string}
|
||||
* @psalm-type DefaultsType = array{
|
||||
* public?: bool,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* }
|
||||
* @psalm-type InstanceofType = array{
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type DefinitionType = array{
|
||||
* class?: string,
|
||||
* file?: string,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* synthetic?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* configurator?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* decorates?: string,
|
||||
* decoration_inner_name?: string,
|
||||
* decoration_priority?: int,
|
||||
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* from_callable?: CallbackType,
|
||||
* }
|
||||
* @psalm-type AliasType = string|array{
|
||||
* alias: string,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type PrototypeType = array{
|
||||
* resource: string,
|
||||
* namespace?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* parent?: string,
|
||||
* shared?: bool,
|
||||
* lazy?: bool|string,
|
||||
* public?: bool,
|
||||
* abstract?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* factory?: CallbackType,
|
||||
* arguments?: ArgumentsType,
|
||||
* properties?: array<string, mixed>,
|
||||
* configurator?: CallbackType,
|
||||
* calls?: list<CallType>,
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* autowire?: bool,
|
||||
* autoconfigure?: bool,
|
||||
* bind?: array<string, mixed>,
|
||||
* constructor?: string,
|
||||
* }
|
||||
* @psalm-type StackType = array{
|
||||
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* }
|
||||
* @psalm-type ServicesConfig = array{
|
||||
* _defaults?: DefaultsType,
|
||||
* _instanceof?: InstanceofType,
|
||||
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||
* }
|
||||
* @psalm-type ExtensionType = array<string, mixed>
|
||||
* @psalm-type FrameworkConfig = array{
|
||||
* secret?: scalar|null|Param,
|
||||
* http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false
|
||||
* allowed_http_method_override?: list<string|Param>|null,
|
||||
* trust_x_sendfile_type_header?: scalar|null|Param, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%"
|
||||
* ide?: scalar|null|Param, // Default: "%env(default::SYMFONY_IDE)%"
|
||||
* test?: bool|Param,
|
||||
* default_locale?: scalar|null|Param, // Default: "en"
|
||||
* set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false
|
||||
* set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false
|
||||
* enabled_locales?: list<scalar|null|Param>,
|
||||
* trusted_hosts?: list<scalar|null|Param>,
|
||||
* trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"]
|
||||
* trusted_headers?: list<scalar|null|Param>,
|
||||
* error_controller?: scalar|null|Param, // Default: "error_controller"
|
||||
* handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true
|
||||
* csrf_protection?: bool|array{
|
||||
* enabled?: scalar|null|Param, // Default: null
|
||||
* stateless_token_ids?: list<scalar|null|Param>,
|
||||
* check_header?: scalar|null|Param, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false
|
||||
* cookie_name?: scalar|null|Param, // The name of the cookie to use when using stateless protection. // Default: "csrf-token"
|
||||
* },
|
||||
* form?: bool|array{ // Form configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* csrf_protection?: array{
|
||||
* enabled?: scalar|null|Param, // Default: null
|
||||
* token_id?: scalar|null|Param, // Default: null
|
||||
* field_name?: scalar|null|Param, // Default: "_token"
|
||||
* field_attr?: array<string, scalar|null|Param>,
|
||||
* },
|
||||
* },
|
||||
* http_cache?: bool|array{ // HTTP cache configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* debug?: bool|Param, // Default: "%kernel.debug%"
|
||||
* trace_level?: "none"|"short"|"full"|Param,
|
||||
* trace_header?: scalar|null|Param,
|
||||
* default_ttl?: int|Param,
|
||||
* private_headers?: list<scalar|null|Param>,
|
||||
* skip_response_headers?: list<scalar|null|Param>,
|
||||
* allow_reload?: bool|Param,
|
||||
* allow_revalidate?: bool|Param,
|
||||
* stale_while_revalidate?: int|Param,
|
||||
* stale_if_error?: int|Param,
|
||||
* terminate_on_cache_hit?: bool|Param,
|
||||
* },
|
||||
* esi?: bool|array{ // ESI configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* ssi?: bool|array{ // SSI configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* fragments?: bool|array{ // Fragments configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* hinclude_default_template?: scalar|null|Param, // Default: null
|
||||
* path?: scalar|null|Param, // Default: "/_fragment"
|
||||
* },
|
||||
* profiler?: bool|array{ // Profiler configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* collect?: bool|Param, // Default: true
|
||||
* collect_parameter?: scalar|null|Param, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null
|
||||
* only_exceptions?: bool|Param, // Default: false
|
||||
* only_main_requests?: bool|Param, // Default: false
|
||||
* dsn?: scalar|null|Param, // Default: "file:%kernel.cache_dir%/profiler"
|
||||
* collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false
|
||||
* },
|
||||
* workflows?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* workflows?: array<string, array{ // Default: []
|
||||
* audit_trail?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* type?: "workflow"|"state_machine"|Param, // Default: "state_machine"
|
||||
* marking_store?: array{
|
||||
* type?: "method"|Param,
|
||||
* property?: scalar|null|Param,
|
||||
* service?: scalar|null|Param,
|
||||
* },
|
||||
* supports?: list<scalar|null|Param>,
|
||||
* definition_validators?: list<scalar|null|Param>,
|
||||
* support_strategy?: scalar|null|Param,
|
||||
* initial_marking?: list<scalar|null|Param>,
|
||||
* events_to_dispatch?: list<string|Param>|null,
|
||||
* places?: list<array{ // Default: []
|
||||
* name: scalar|null|Param,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* transitions: list<array{ // Default: []
|
||||
* name: string|Param,
|
||||
* guard?: string|Param, // An expression to block the transition.
|
||||
* from?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* to?: list<array{ // Default: []
|
||||
* place: string|Param,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* }>,
|
||||
* weight?: int|Param, // Default: 1
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* metadata?: list<mixed>,
|
||||
* }>,
|
||||
* },
|
||||
* router?: bool|array{ // Router configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resource: scalar|null|Param,
|
||||
* type?: scalar|null|Param,
|
||||
* cache_dir?: scalar|null|Param, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%"
|
||||
* default_uri?: scalar|null|Param, // The default URI used to generate URLs in a non-HTTP context. // Default: null
|
||||
* http_port?: scalar|null|Param, // Default: 80
|
||||
* https_port?: scalar|null|Param, // Default: 443
|
||||
* strict_requirements?: scalar|null|Param, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true
|
||||
* utf8?: bool|Param, // Default: true
|
||||
* },
|
||||
* session?: bool|array{ // Session configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* storage_factory_id?: scalar|null|Param, // Default: "session.storage.factory.native"
|
||||
* handler_id?: scalar|null|Param, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null.
|
||||
* name?: scalar|null|Param,
|
||||
* cookie_lifetime?: scalar|null|Param,
|
||||
* cookie_path?: scalar|null|Param,
|
||||
* cookie_domain?: scalar|null|Param,
|
||||
* cookie_secure?: true|false|"auto"|Param, // Default: "auto"
|
||||
* cookie_httponly?: bool|Param, // Default: true
|
||||
* cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
|
||||
* use_cookies?: bool|Param,
|
||||
* gc_divisor?: scalar|null|Param,
|
||||
* gc_probability?: scalar|null|Param,
|
||||
* gc_maxlifetime?: scalar|null|Param,
|
||||
* save_path?: scalar|null|Param, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null.
|
||||
* metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0
|
||||
* sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option.
|
||||
* },
|
||||
* request?: bool|array{ // Request configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* formats?: array<string, string|list<scalar|null|Param>>,
|
||||
* },
|
||||
* assets?: bool|array{ // Assets configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|null|Param, // Default: null
|
||||
* version?: scalar|null|Param, // Default: null
|
||||
* version_format?: scalar|null|Param, // Default: "%%s?%%s"
|
||||
* json_manifest_path?: scalar|null|Param, // Default: null
|
||||
* base_path?: scalar|null|Param, // Default: ""
|
||||
* base_urls?: list<scalar|null|Param>,
|
||||
* packages?: array<string, array{ // Default: []
|
||||
* strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false
|
||||
* version_strategy?: scalar|null|Param, // Default: null
|
||||
* version?: scalar|null|Param,
|
||||
* version_format?: scalar|null|Param, // Default: null
|
||||
* json_manifest_path?: scalar|null|Param, // Default: null
|
||||
* base_path?: scalar|null|Param, // Default: ""
|
||||
* base_urls?: list<scalar|null|Param>,
|
||||
* }>,
|
||||
* },
|
||||
* asset_mapper?: bool|array{ // Asset Mapper configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* paths?: array<string, scalar|null|Param>,
|
||||
* excluded_patterns?: list<scalar|null|Param>,
|
||||
* exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true
|
||||
* server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true
|
||||
* public_prefix?: scalar|null|Param, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/"
|
||||
* missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn"
|
||||
* extensions?: array<string, scalar|null|Param>,
|
||||
* importmap_path?: scalar|null|Param, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php"
|
||||
* importmap_polyfill?: scalar|null|Param, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims"
|
||||
* importmap_script_attributes?: array<string, scalar|null|Param>,
|
||||
* vendor_dir?: scalar|null|Param, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor"
|
||||
* precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* formats?: list<scalar|null|Param>,
|
||||
* extensions?: list<scalar|null|Param>,
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* fallbacks?: list<scalar|null|Param>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* formatter?: scalar|null|Param, // Default: "translator.formatter.default"
|
||||
* cache_dir?: scalar|null|Param, // Default: "%kernel.cache_dir%/translations"
|
||||
* default_path?: scalar|null|Param, // The default path used to load translations. // Default: "%kernel.project_dir%/translations"
|
||||
* paths?: list<scalar|null|Param>,
|
||||
* pseudo_localization?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* accents?: bool|Param, // Default: true
|
||||
* expansion_factor?: float|Param, // Default: 1.0
|
||||
* brackets?: bool|Param, // Default: true
|
||||
* parse_html?: bool|Param, // Default: false
|
||||
* localizable_html_attributes?: list<scalar|null|Param>,
|
||||
* },
|
||||
* providers?: array<string, array{ // Default: []
|
||||
* dsn?: scalar|null|Param,
|
||||
* domains?: list<scalar|null|Param>,
|
||||
* locales?: list<scalar|null|Param>,
|
||||
* }>,
|
||||
* globals?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* message?: string|Param,
|
||||
* parameters?: array<string, scalar|null|Param>,
|
||||
* domain?: string|Param,
|
||||
* }>,
|
||||
* },
|
||||
* validation?: bool|array{ // Validation configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache?: scalar|null|Param, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0.
|
||||
* enable_attributes?: bool|Param, // Default: true
|
||||
* static_method?: list<scalar|null|Param>,
|
||||
* translation_domain?: scalar|null|Param, // Default: "validators"
|
||||
* email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5"
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|null|Param>,
|
||||
* },
|
||||
* not_compromised_password?: bool|array{
|
||||
* enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true
|
||||
* endpoint?: scalar|null|Param, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
||||
* },
|
||||
* disable_translation?: bool|Param, // Default: false
|
||||
* auto_mapping?: array<string, array{ // Default: []
|
||||
* services?: list<scalar|null|Param>,
|
||||
* }>,
|
||||
* },
|
||||
* annotations?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* serializer?: bool|array{ // Serializer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enable_attributes?: bool|Param, // Default: true
|
||||
* name_converter?: scalar|null|Param,
|
||||
* circular_reference_handler?: scalar|null|Param,
|
||||
* max_depth_handler?: scalar|null|Param,
|
||||
* mapping?: array{
|
||||
* paths?: list<scalar|null|Param>,
|
||||
* },
|
||||
* default_context?: list<mixed>,
|
||||
* named_serializers?: array<string, array{ // Default: []
|
||||
* name_converter?: scalar|null|Param,
|
||||
* default_context?: list<mixed>,
|
||||
* include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true
|
||||
* include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true
|
||||
* }>,
|
||||
* },
|
||||
* property_access?: bool|array{ // Property access configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* magic_call?: bool|Param, // Default: false
|
||||
* magic_get?: bool|Param, // Default: true
|
||||
* magic_set?: bool|Param, // Default: true
|
||||
* throw_exception_on_invalid_index?: bool|Param, // Default: false
|
||||
* throw_exception_on_invalid_property_path?: bool|Param, // Default: true
|
||||
* },
|
||||
* type_info?: bool|array{ // Type info configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* aliases?: array<string, scalar|null|Param>,
|
||||
* },
|
||||
* property_info?: bool|array{ // Property info configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* with_constructor_extractor?: bool|Param, // Registers the constructor extractor.
|
||||
* },
|
||||
* cache?: array{ // Cache configuration
|
||||
* prefix_seed?: scalar|null|Param, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%"
|
||||
* app?: scalar|null|Param, // App related cache pools configuration. // Default: "cache.adapter.filesystem"
|
||||
* system?: scalar|null|Param, // System related cache pools configuration. // Default: "cache.adapter.system"
|
||||
* directory?: scalar|null|Param, // Default: "%kernel.share_dir%/pools/app"
|
||||
* default_psr6_provider?: scalar|null|Param,
|
||||
* default_redis_provider?: scalar|null|Param, // Default: "redis://localhost"
|
||||
* default_valkey_provider?: scalar|null|Param, // Default: "valkey://localhost"
|
||||
* default_memcached_provider?: scalar|null|Param, // Default: "memcached://localhost"
|
||||
* default_doctrine_dbal_provider?: scalar|null|Param, // Default: "database_connection"
|
||||
* default_pdo_provider?: scalar|null|Param, // Default: null
|
||||
* pools?: array<string, array{ // Default: []
|
||||
* adapters?: list<scalar|null|Param>,
|
||||
* tags?: scalar|null|Param, // Default: null
|
||||
* public?: bool|Param, // Default: false
|
||||
* default_lifetime?: scalar|null|Param, // Default lifetime of the pool.
|
||||
* provider?: scalar|null|Param, // Overwrite the setting from the default provider for this adapter.
|
||||
* early_expiration_message_bus?: scalar|null|Param,
|
||||
* clearer?: scalar|null|Param,
|
||||
* }>,
|
||||
* },
|
||||
* php_errors?: array{ // PHP errors handling configuration
|
||||
* log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true
|
||||
* throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true
|
||||
* },
|
||||
* exceptions?: array<string, array{ // Default: []
|
||||
* log_level?: scalar|null|Param, // The level of log message. Null to let Symfony decide. // Default: null
|
||||
* status_code?: scalar|null|Param, // The status code of the response. Null or 0 to let Symfony decide. // Default: null
|
||||
* log_channel?: scalar|null|Param, // The channel of log message. Null to let Symfony decide. // Default: null
|
||||
* }>,
|
||||
* web_link?: bool|array{ // Web links configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resources?: array<string, string|list<scalar|null|Param>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resources?: array<string, scalar|null|Param>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* senders?: list<scalar|null|Param>,
|
||||
* }>,
|
||||
* serializer?: array{
|
||||
* default_serializer?: scalar|null|Param, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
||||
* symfony_serializer?: array{
|
||||
* format?: scalar|null|Param, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json"
|
||||
* context?: array<string, mixed>,
|
||||
* },
|
||||
* },
|
||||
* transports?: array<string, string|array{ // Default: []
|
||||
* dsn?: scalar|null|Param,
|
||||
* serializer?: scalar|null|Param, // Service id of a custom serializer to use. // Default: null
|
||||
* options?: list<mixed>,
|
||||
* failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* retry_strategy?: string|array{
|
||||
* service?: scalar|null|Param, // Service id to override the retry strategy entirely. // Default: null
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1
|
||||
* },
|
||||
* rate_limiter?: scalar|null|Param, // Rate limiter name to use when processing messages. // Default: null
|
||||
* }>,
|
||||
* failure_transport?: scalar|null|Param, // Transport name to send failed messages to (after all retries have failed). // Default: null
|
||||
* stop_worker_on_signals?: list<scalar|null|Param>,
|
||||
* default_bus?: scalar|null|Param, // Default: null
|
||||
* buses?: array<string, array{ // Default: {"messenger.bus.default":{"default_middleware":{"enabled":true,"allow_no_handlers":false,"allow_no_senders":true},"middleware":[]}}
|
||||
* default_middleware?: bool|string|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* allow_no_handlers?: bool|Param, // Default: false
|
||||
* allow_no_senders?: bool|Param, // Default: true
|
||||
* },
|
||||
* middleware?: list<string|array{ // Default: []
|
||||
* id: scalar|null|Param,
|
||||
* arguments?: list<mixed>,
|
||||
* }>,
|
||||
* }>,
|
||||
* },
|
||||
* scheduler?: bool|array{ // Scheduler configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
* vars?: array<string, mixed>,
|
||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|null|Param>,
|
||||
* proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|null|Param, // A certificate authority file.
|
||||
* capath?: scalar|null|Param, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|null|Param, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|null|Param, // A private key file.
|
||||
* passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...)
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int|Param,
|
||||
* methods?: list<string|Param>,
|
||||
* }>,
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* },
|
||||
* mock_response_factory?: scalar|null|Param, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
|
||||
* scoped_clients?: array<string, string|array{ // Default: []
|
||||
* scope?: scalar|null|Param, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
|
||||
* base_uri?: scalar|null|Param, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
||||
* auth_basic?: scalar|null|Param, // An HTTP Basic authentication "username:password".
|
||||
* auth_bearer?: scalar|null|Param, // A token enabling HTTP Bearer authorization.
|
||||
* auth_ntlm?: scalar|null|Param, // A "username:password" pair to use Microsoft NTLM authentication (requires the cURL extension).
|
||||
* query?: array<string, scalar|null|Param>,
|
||||
* headers?: array<string, mixed>,
|
||||
* max_redirects?: int|Param, // The maximum number of redirects to follow.
|
||||
* http_version?: scalar|null|Param, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version.
|
||||
* resolve?: array<string, scalar|null|Param>,
|
||||
* proxy?: scalar|null|Param, // The URL of the proxy to pass requests through or null for automatic detection.
|
||||
* no_proxy?: scalar|null|Param, // A comma separated list of hosts that do not require a proxy to be reached.
|
||||
* timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter.
|
||||
* max_duration?: float|Param, // The maximum execution time for the request+response as a whole.
|
||||
* bindto?: scalar|null|Param, // A network interface name, IP address, a host name or a UNIX socket to bind to.
|
||||
* verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context.
|
||||
* verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name.
|
||||
* cafile?: scalar|null|Param, // A certificate authority file.
|
||||
* capath?: scalar|null|Param, // A directory that contains multiple certificate authority files.
|
||||
* local_cert?: scalar|null|Param, // A PEM formatted certificate file.
|
||||
* local_pk?: scalar|null|Param, // A private key file.
|
||||
* passphrase?: scalar|null|Param, // The passphrase used to encrypt the "local_pk" file.
|
||||
* ciphers?: scalar|null|Param, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...).
|
||||
* peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es).
|
||||
* sha1?: mixed,
|
||||
* pin-sha256?: mixed,
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|null|Param, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|null|Param, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* retry_strategy?: scalar|null|Param, // service id to override the retry strategy. // Default: null
|
||||
* http_codes?: array<string, array{ // Default: []
|
||||
* code?: int|Param,
|
||||
* methods?: list<string|Param>,
|
||||
* }>,
|
||||
* max_retries?: int|Param, // Default: 3
|
||||
* delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000
|
||||
* multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2
|
||||
* max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0
|
||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* mailer?: bool|array{ // Mailer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* dsn?: scalar|null|Param, // Default: null
|
||||
* transports?: array<string, scalar|null|Param>,
|
||||
* envelope?: array{ // Mailer Envelope configuration
|
||||
* sender?: scalar|null|Param,
|
||||
* recipients?: list<scalar|null|Param>,
|
||||
* allowed_recipients?: list<scalar|null|Param>,
|
||||
* },
|
||||
* headers?: array<string, string|array{ // Default: []
|
||||
* value?: mixed,
|
||||
* }>,
|
||||
* dkim_signer?: bool|array{ // DKIM signer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key?: scalar|null|Param, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: ""
|
||||
* domain?: scalar|null|Param, // Default: ""
|
||||
* select?: scalar|null|Param, // Default: ""
|
||||
* passphrase?: scalar|null|Param, // The private key passphrase // Default: ""
|
||||
* options?: array<string, mixed>,
|
||||
* },
|
||||
* smime_signer?: bool|array{ // S/MIME signer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* key?: scalar|null|Param, // Path to key (in PEM format) // Default: ""
|
||||
* certificate?: scalar|null|Param, // Path to certificate (in PEM format without the `file://` prefix) // Default: ""
|
||||
* passphrase?: scalar|null|Param, // The private key passphrase // Default: null
|
||||
* extra_certificates?: scalar|null|Param, // Default: null
|
||||
* sign_options?: int|Param, // Default: null
|
||||
* },
|
||||
* smime_encrypter?: bool|array{ // S/MIME encrypter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* repository?: scalar|null|Param, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: ""
|
||||
* cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null
|
||||
* },
|
||||
* },
|
||||
* secrets?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* vault_directory?: scalar|null|Param, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%"
|
||||
* local_dotenv_file?: scalar|null|Param, // Default: "%kernel.project_dir%/.env.%kernel.runtime_environment%.local"
|
||||
* decryption_env_var?: scalar|null|Param, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET"
|
||||
* },
|
||||
* notifier?: bool|array{ // Notifier configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|null|Param, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null
|
||||
* chatter_transports?: array<string, scalar|null|Param>,
|
||||
* texter_transports?: array<string, scalar|null|Param>,
|
||||
* notification_on_failed_messages?: bool|Param, // Default: false
|
||||
* channel_policy?: array<string, string|list<scalar|null|Param>>,
|
||||
* admin_recipients?: list<array{ // Default: []
|
||||
* email?: scalar|null|Param,
|
||||
* phone?: scalar|null|Param, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* limiters?: array<string, array{ // Default: []
|
||||
* lock_factory?: scalar|null|Param, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||
* cache_pool?: scalar|null|Param, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||
* storage_service?: scalar|null|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
|
||||
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
|
||||
* limiters?: list<scalar|null|Param>,
|
||||
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
|
||||
* interval?: scalar|null|Param, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket".
|
||||
* interval?: scalar|null|Param, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* amount?: int|Param, // Amount of tokens to add each interval. // Default: 1
|
||||
* },
|
||||
* }>,
|
||||
* },
|
||||
* uid?: bool|array{ // Uid configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* default_uuid_version?: 7|6|4|1|Param, // Default: 7
|
||||
* name_based_uuid_version?: 5|3|Param, // Default: 5
|
||||
* name_based_uuid_namespace?: scalar|null|Param,
|
||||
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
|
||||
* time_based_uuid_node?: scalar|null|Param,
|
||||
* },
|
||||
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* sanitizers?: array<string, array{ // Default: []
|
||||
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
|
||||
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
||||
* allow_elements?: array<string, mixed>,
|
||||
* block_elements?: list<string|Param>,
|
||||
* drop_elements?: list<string|Param>,
|
||||
* allow_attributes?: array<string, mixed>,
|
||||
* drop_attributes?: array<string, mixed>,
|
||||
* force_attributes?: array<string, array<string, string|Param>>,
|
||||
* force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false
|
||||
* allowed_link_schemes?: list<string|Param>,
|
||||
* allowed_link_hosts?: list<string|Param>|null,
|
||||
* allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false
|
||||
* allowed_media_schemes?: list<string|Param>,
|
||||
* allowed_media_hosts?: list<string|Param>|null,
|
||||
* allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false
|
||||
* with_attribute_sanitizers?: list<string|Param>,
|
||||
* without_attribute_sanitizers?: list<string|Param>,
|
||||
* max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0
|
||||
* }>,
|
||||
* },
|
||||
* webhook?: bool|array{ // Webhook configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|null|Param, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service: scalar|null|Param,
|
||||
* secret?: scalar|null|Param, // Default: ""
|
||||
* }>,
|
||||
* },
|
||||
* remote-event?: bool|array{ // RemoteEvent configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* json_streamer?: bool|array{ // JSON streamer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|null|Param, // Default: true
|
||||
* channels?: list<scalar|null|Param>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|null|Param,
|
||||
* id?: scalar|null|Param,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|null|Param, // Default: 0
|
||||
* level?: scalar|null|Param, // Default: "DEBUG"
|
||||
* bubble?: bool|Param, // Default: true
|
||||
* interactive_only?: bool|Param, // Default: false
|
||||
* app_name?: scalar|null|Param, // Default: null
|
||||
* include_stacktraces?: bool|Param, // Default: false
|
||||
* process_psr_3_messages?: array{
|
||||
* enabled?: bool|null|Param, // Default: null
|
||||
* date_format?: scalar|null|Param,
|
||||
* remove_used_context_fields?: bool|Param,
|
||||
* },
|
||||
* path?: scalar|null|Param, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
* file_permission?: scalar|null|Param, // Default: null
|
||||
* use_locking?: bool|Param, // Default: false
|
||||
* filename_format?: scalar|null|Param, // Default: "{filename}-{date}"
|
||||
* date_format?: scalar|null|Param, // Default: "Y-m-d"
|
||||
* ident?: scalar|null|Param, // Default: false
|
||||
* logopts?: scalar|null|Param, // Default: 1
|
||||
* facility?: scalar|null|Param, // Default: "user"
|
||||
* max_files?: scalar|null|Param, // Default: 0
|
||||
* action_level?: scalar|null|Param, // Default: "WARNING"
|
||||
* activation_strategy?: scalar|null|Param, // Default: null
|
||||
* stop_buffering?: bool|Param, // Default: true
|
||||
* passthru_level?: scalar|null|Param, // Default: null
|
||||
* excluded_http_codes?: list<array{ // Default: []
|
||||
* code?: scalar|null|Param,
|
||||
* urls?: list<scalar|null|Param>,
|
||||
* }>,
|
||||
* accepted_levels?: list<scalar|null|Param>,
|
||||
* min_level?: scalar|null|Param, // Default: "DEBUG"
|
||||
* max_level?: scalar|null|Param, // Default: "EMERGENCY"
|
||||
* buffer_size?: scalar|null|Param, // Default: 0
|
||||
* flush_on_overflow?: bool|Param, // Default: false
|
||||
* handler?: scalar|null|Param,
|
||||
* url?: scalar|null|Param,
|
||||
* exchange?: scalar|null|Param,
|
||||
* exchange_name?: scalar|null|Param, // Default: "log"
|
||||
* channel?: scalar|null|Param, // Default: null
|
||||
* bot_name?: scalar|null|Param, // Default: "Monolog"
|
||||
* use_attachment?: scalar|null|Param, // Default: true
|
||||
* use_short_attachment?: scalar|null|Param, // Default: false
|
||||
* include_extra?: scalar|null|Param, // Default: false
|
||||
* icon_emoji?: scalar|null|Param, // Default: null
|
||||
* webhook_url?: scalar|null|Param,
|
||||
* exclude_fields?: list<scalar|null|Param>,
|
||||
* token?: scalar|null|Param,
|
||||
* region?: scalar|null|Param,
|
||||
* source?: scalar|null|Param,
|
||||
* use_ssl?: bool|Param, // Default: true
|
||||
* user?: mixed,
|
||||
* title?: scalar|null|Param, // Default: null
|
||||
* host?: scalar|null|Param, // Default: null
|
||||
* port?: scalar|null|Param, // Default: 514
|
||||
* config?: list<scalar|null|Param>,
|
||||
* members?: list<scalar|null|Param>,
|
||||
* connection_string?: scalar|null|Param,
|
||||
* timeout?: scalar|null|Param,
|
||||
* time?: scalar|null|Param, // Default: 60
|
||||
* deduplication_level?: scalar|null|Param, // Default: 400
|
||||
* store?: scalar|null|Param, // Default: null
|
||||
* connection_timeout?: scalar|null|Param,
|
||||
* persistent?: bool|Param,
|
||||
* message_type?: scalar|null|Param, // Default: 0
|
||||
* parse_mode?: scalar|null|Param, // Default: null
|
||||
* disable_webpage_preview?: bool|null|Param, // Default: null
|
||||
* disable_notification?: bool|null|Param, // Default: null
|
||||
* split_long_messages?: bool|Param, // Default: false
|
||||
* delay_between_messages?: bool|Param, // Default: false
|
||||
* topic?: int|Param, // Default: null
|
||||
* factor?: int|Param, // Default: 1
|
||||
* tags?: list<scalar|null|Param>,
|
||||
* console_formatter_options?: mixed, // Default: []
|
||||
* formatter?: scalar|null|Param,
|
||||
* nested?: bool|Param, // Default: false
|
||||
* publisher?: string|array{
|
||||
* id?: scalar|null|Param,
|
||||
* hostname?: scalar|null|Param,
|
||||
* port?: scalar|null|Param, // Default: 12201
|
||||
* chunk_size?: scalar|null|Param, // Default: 1420
|
||||
* encoder?: "json"|"compressed_json"|Param,
|
||||
* },
|
||||
* mongodb?: string|array{
|
||||
* id?: scalar|null|Param, // ID of a MongoDB\Client service
|
||||
* uri?: scalar|null|Param,
|
||||
* username?: scalar|null|Param,
|
||||
* password?: scalar|null|Param,
|
||||
* database?: scalar|null|Param, // Default: "monolog"
|
||||
* collection?: scalar|null|Param, // Default: "logs"
|
||||
* },
|
||||
* elasticsearch?: string|array{
|
||||
* id?: scalar|null|Param,
|
||||
* hosts?: list<scalar|null|Param>,
|
||||
* host?: scalar|null|Param,
|
||||
* port?: scalar|null|Param, // Default: 9200
|
||||
* transport?: scalar|null|Param, // Default: "Http"
|
||||
* user?: scalar|null|Param, // Default: null
|
||||
* password?: scalar|null|Param, // Default: null
|
||||
* },
|
||||
* index?: scalar|null|Param, // Default: "monolog"
|
||||
* document_type?: scalar|null|Param, // Default: "logs"
|
||||
* ignore_error?: scalar|null|Param, // Default: false
|
||||
* redis?: string|array{
|
||||
* id?: scalar|null|Param,
|
||||
* host?: scalar|null|Param,
|
||||
* password?: scalar|null|Param, // Default: null
|
||||
* port?: scalar|null|Param, // Default: 6379
|
||||
* database?: scalar|null|Param, // Default: 0
|
||||
* key_name?: scalar|null|Param, // Default: "monolog_redis"
|
||||
* },
|
||||
* predis?: string|array{
|
||||
* id?: scalar|null|Param,
|
||||
* host?: scalar|null|Param,
|
||||
* },
|
||||
* from_email?: scalar|null|Param,
|
||||
* to_email?: list<scalar|null|Param>,
|
||||
* subject?: scalar|null|Param,
|
||||
* content_type?: scalar|null|Param, // Default: null
|
||||
* headers?: list<scalar|null|Param>,
|
||||
* mailer?: scalar|null|Param, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|null|Param,
|
||||
* method?: scalar|null|Param, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
* VERBOSITY_QUIET?: scalar|null|Param, // Default: "ERROR"
|
||||
* VERBOSITY_NORMAL?: scalar|null|Param, // Default: "WARNING"
|
||||
* VERBOSITY_VERBOSE?: scalar|null|Param, // Default: "NOTICE"
|
||||
* VERBOSITY_VERY_VERBOSE?: scalar|null|Param, // Default: "INFO"
|
||||
* VERBOSITY_DEBUG?: scalar|null|Param, // Default: "DEBUG"
|
||||
* },
|
||||
* channels?: string|array{
|
||||
* type?: scalar|null|Param,
|
||||
* elements?: list<scalar|null|Param>,
|
||||
* },
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* ...<string, ExtensionType>,
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
final class App
|
||||
{
|
||||
/**
|
||||
* @param ConfigType $config
|
||||
*
|
||||
* @psalm-return ConfigType
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return AppReference::config($config);
|
||||
}
|
||||
}
|
||||
|
||||
namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
|
||||
/**
|
||||
* This class provides array-shapes for configuring the routes of an application.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```php
|
||||
* // config/routes.php
|
||||
* namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
*
|
||||
* return Routes::config([
|
||||
* 'controllers' => [
|
||||
* 'resource' => 'routing.controllers',
|
||||
* ],
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @psalm-type RouteConfig = array{
|
||||
* path: string|array<string,string>,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type ImportConfig = array{
|
||||
* resource: string,
|
||||
* type?: string,
|
||||
* exclude?: string|list<string>,
|
||||
* prefix?: string|array<string,string>,
|
||||
* name_prefix?: string,
|
||||
* trailing_slash_on_root?: bool,
|
||||
* controller?: string,
|
||||
* methods?: string|list<string>,
|
||||
* requirements?: array<string,string>,
|
||||
* defaults?: array<string,mixed>,
|
||||
* options?: array<string,mixed>,
|
||||
* host?: string|array<string,string>,
|
||||
* schemes?: string|list<string>,
|
||||
* condition?: string,
|
||||
* locale?: string,
|
||||
* format?: string,
|
||||
* utf8?: bool,
|
||||
* stateless?: bool,
|
||||
* }
|
||||
* @psalm-type AliasConfig = array{
|
||||
* alias: string,
|
||||
* deprecated?: array{package:string, version:string, message?:string},
|
||||
* }
|
||||
* @psalm-type RoutesConfig = array{
|
||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||
* }
|
||||
*/
|
||||
final class Routes
|
||||
{
|
||||
/**
|
||||
* @param RoutesConfig $config
|
||||
*
|
||||
* @psalm-return RoutesConfig
|
||||
*/
|
||||
public static function config(array $config): array
|
||||
{
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
6
config/routes.yaml
Normal file
6
config/routes.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
#index:
|
||||
# path: /
|
||||
# controller: App\Controller\DefaultController::index
|
||||
controllers:
|
||||
resource: ../src/Controller/
|
||||
type: attribute
|
||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
||||
prefix: /_error
|
||||
106
config/services.yaml
Normal file
106
config/services.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
#
|
||||
# https://symfony.com/doc/current/best_practices.html
|
||||
|
||||
parameters:
|
||||
|
||||
services:
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Default service configuration
|
||||
# ------------------------------------------------------------
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
# Bind the agent-specific Monolog channel explicitly
|
||||
bind:
|
||||
Psr\Log\LoggerInterface $agentLogger: '@monolog.logger.agent'
|
||||
string $projectDir: '%kernel.project_dir%'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Automatically register all services in src/
|
||||
# ------------------------------------------------------------
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# AI Agent – Infrastructure
|
||||
# ------------------------------------------------------------
|
||||
App\Infrastructure\OllamaClient:
|
||||
arguments:
|
||||
$apiUrl: '%env(AI_LLM_API_URL)%'
|
||||
$model: '%env(AI_LLM_MODEL)%'
|
||||
$timeoutSeconds: '%env(int:AI_LLM_TIMEOUT)%'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# AI Agent – Context & state
|
||||
# ------------------------------------------------------------
|
||||
App\Context\ContextService:
|
||||
arguments:
|
||||
$historyDir: '%env(AI_HISTORY_DIR)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# AI Agent – Debug & logging flags
|
||||
# ------------------------------------------------------------
|
||||
App\Agent\AgentRunner:
|
||||
arguments:
|
||||
$debug: '%env(bool:AI_DEBUG)%'
|
||||
$logPrompt: '%env(bool:AI_LOG_PROMPT)%'
|
||||
$logContext: '%env(bool:AI_LOG_CONTEXT)%'
|
||||
|
||||
App\Controller\:
|
||||
resource: '../src/Controller/'
|
||||
tags: [ 'controller.service_arguments' ]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# AI Agent – Knowledge
|
||||
# ------------------------------------------------------------
|
||||
App\Knowledge\Retrieval\ChunkKeywordRetriever:
|
||||
arguments:
|
||||
$chunksDir: '%kernel.project_dir%/var/knowledge/chunks'
|
||||
|
||||
App\Knowledge\Retrieval\CachedRetriever:
|
||||
arguments:
|
||||
$inner: '@App\Knowledge\Retrieval\ChunkKeywordRetriever'
|
||||
$cache: '@cache.app'
|
||||
$ttlSeconds: 600
|
||||
|
||||
App\Knowledge\Retrieval\RetrieverInterface:
|
||||
alias: App\Knowledge\Retrieval\CachedRetriever
|
||||
|
||||
App\Knowledge\Ingest\ChunkWriter:
|
||||
arguments:
|
||||
$chunksDir: '%kernel.project_dir%/var/knowledge/chunks'
|
||||
$manifestPath: '%kernel.project_dir%/var/knowledge/manifest.json'
|
||||
|
||||
App\Knowledge\Ingest\ChunkIndexWriter:
|
||||
arguments:
|
||||
$indexPath: '%kernel.project_dir%/var/knowledge/index.json'
|
||||
|
||||
App\Knowledge\Retrieval\ChunkIndexLoader:
|
||||
arguments:
|
||||
$indexPath: '%kernel.project_dir%/var/knowledge/index.json'
|
||||
|
||||
App\Command\KnowledgeIngestCommand:
|
||||
arguments:
|
||||
$uploadsDir: '%kernel.project_dir%/var/knowledge/uploads'
|
||||
|
||||
App\Vector\VectorSearchClient:
|
||||
arguments:
|
||||
$vectorDir: '%kernel.project_dir%/src/Vector'
|
||||
|
||||
App\Command\VectorIngestCommand:
|
||||
arguments:
|
||||
$vectorDir: '%kernel.project_dir%/src/Vector'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Command\VectorInstallCommand:
|
||||
arguments:
|
||||
$vectorDir: '%kernel.project_dir%/src/Vector'
|
||||
159
public/assets/js/base.js
Normal file
159
public/assets/js/base.js
Normal file
@@ -0,0 +1,159 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatEl = document.getElementById('chat');
|
||||
const promptEl = document.getElementById('prompt');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const abortBtn = document.getElementById('abort');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
|
||||
let abort = false;
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
|
||||
function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text));
|
||||
}
|
||||
|
||||
function addMessage(role, html = '', extra = '') {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'message ' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble ' + extra;
|
||||
bubble.innerHTML = html;
|
||||
|
||||
msg.appendChild(bubble);
|
||||
chatEl.appendChild(msg);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function addLoader() {
|
||||
return addMessage('assistant', 'AI is thinking…', 'loader');
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/history');
|
||||
if (!res.ok) return;
|
||||
const messages = await res.json();
|
||||
messages.forEach(m =>
|
||||
addMessage(m.role, renderMarkdown(m.text))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (!prompt) return;
|
||||
|
||||
addMessage('user', renderMarkdown(prompt));
|
||||
promptEl.value = '';
|
||||
|
||||
const bubble = addLoader();
|
||||
let raw = '';
|
||||
let firstChunk = true;
|
||||
let renderTimer = null;
|
||||
|
||||
// 🔥 LÖSUNG: Throttled Rendering - maximal alle 100ms
|
||||
function scheduleRender() {
|
||||
if (renderTimer) return;
|
||||
|
||||
renderTimer = setTimeout(() => {
|
||||
// Der StreamChunker sendet bereits korrekt strukturierte Chunks
|
||||
bubble.innerHTML = renderMarkdown(raw);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
renderTimer = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
abort = false;
|
||||
sendBtn.disabled = true;
|
||||
abortBtn.disabled = false;
|
||||
clearBtn.disabled = true;
|
||||
|
||||
try {
|
||||
document.getElementById('ai-cloud')?.classList.remove('d-none');
|
||||
|
||||
const res = await fetch('/ask-sse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt })
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
while (!abort) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
sseBuffer += chunk;
|
||||
|
||||
// Parse SSE Events (können mehrere data:-Zeilen haben)
|
||||
const events = sseBuffer.split('\n\n');
|
||||
sseBuffer = events.pop() || ''; // Letzten unvollständigen Event behalten
|
||||
|
||||
events.forEach(event => {
|
||||
if (!event.trim()) return;
|
||||
|
||||
// Sammle alle "data:"-Zeilen und füge \n wieder ein
|
||||
const dataLines = event
|
||||
.split('\n')
|
||||
.filter(line => line.startsWith('data: '))
|
||||
.map(line => line.slice(6));
|
||||
|
||||
if (dataLines.length === 0) return;
|
||||
|
||||
// Verbinde mit \n (so wie es vom Backend kam)
|
||||
const text = dataLines.join('\n');
|
||||
|
||||
if (text === '[DONE]') {
|
||||
// Finales Rendering mit Normalisierung
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = null;
|
||||
}
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
abort = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstChunk) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
firstChunk = false;
|
||||
document.getElementById('ai-cloud')?.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Text sammeln und verzögert rendern
|
||||
raw += text;
|
||||
scheduleRender();
|
||||
});
|
||||
}
|
||||
|
||||
} catch {
|
||||
bubble.innerHTML += '<br><em>Error occurred.</em>';
|
||||
} finally {
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
sendBtn.disabled = false;
|
||||
abortBtn.disabled = true;
|
||||
clearBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
abortBtn.addEventListener('click', () => {
|
||||
abort = true;
|
||||
addMessage('assistant', '<em>[aborted]</em>');
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
await fetch('/history/delete', { method: 'POST' });
|
||||
chatEl.innerHTML = '';
|
||||
addMessage('assistant', '<em>History cleared.</em>');
|
||||
});
|
||||
});
|
||||
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
69
public/assets/js/marked.min.js
vendored
Normal file
69
public/assets/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
public/assets/js/purify.min.js
vendored
Normal file
3
public/assets/js/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
236
public/assets/styles/base.css
Normal file
236
public/assets/styles/base.css
Normal file
@@ -0,0 +1,236 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--panel: #020617;
|
||||
--border: #334155;
|
||||
--text: #d3d6dc;
|
||||
--muted: #94a3b8;
|
||||
--accent: #2563eb;
|
||||
--user: #1e40af;
|
||||
--assistant: #020617;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.49rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.31rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.13rem;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
form, input, button, textarea {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--user);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message.assistant .bubble {
|
||||
background: var(--assistant);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bubble.loader {
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.bubble p {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.bubble ul {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.bubble code {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.bubble pre {
|
||||
background: #020617;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bubble h1,
|
||||
.bubble h2,
|
||||
.bubble h3,
|
||||
.bubble h4,
|
||||
.bubble h5,
|
||||
.bubble h6{
|
||||
margin-top: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bubble table {
|
||||
width: 99%
|
||||
}
|
||||
.bubble td {
|
||||
border-top: 1px dotted;
|
||||
padding: .5rem .5rem .5rem .0rem;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
textarea{
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--muted);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--muted) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
textarea::-webkit-input-placeholder {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
textarea::-moz-placeholder {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
textarea:-ms-input-placeholder {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
textarea:-moz-placeholder {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bg-dark {
|
||||
background-color: var(--assistant) !important;
|
||||
color: #fff;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-trans {
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-trans:disabled {
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
opacity: .4 !important;
|
||||
}
|
||||
|
||||
.ai-cloud {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(37, 99, 235, 0.4), transparent 70%),
|
||||
radial-gradient(circle at 70% 70%, rgba(148, 163, 184, 0.3), transparent 80%),
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.1), transparent 100%);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
filter: blur(30px);
|
||||
animation: cloud-move 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cloud-move {
|
||||
0%, 100% {
|
||||
transform: translateX(-50%) scale(.5);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.chat {
|
||||
position: relative;
|
||||
/* ... vorhandene Styles ... */
|
||||
}
|
||||
|
||||
#ai-cloud{
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
}
|
||||
6
public/assets/styles/bootstrap.min.css
vendored
Normal file
6
public/assets/styles/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
36
public/index.html
Normal file
36
public/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AI Agent</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Markdown + Sanitizer -->
|
||||
<link href="/assets/styles/bootstrap.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/marked.min.js"></script>
|
||||
<script src="/assets/js/purify.min.js"></script>
|
||||
<script src="/assets/js/base.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>mitho AI Agent</h1>
|
||||
<div class="spacer"></div>
|
||||
<button id="clear" class="btn btn-trans">Diesen Chat löschen</button>
|
||||
</div>
|
||||
<div id="ai-cloud" class="ai-cloud d-none"></div>
|
||||
<div id="chat" class="chat"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="prompt" class="form-control bg-dark" placeholder="Stelle eine Frage"></textarea>
|
||||
<button id="send" class="btn btn-trans">Send</button>
|
||||
<button id="abort" class="btn btn-trans" disabled>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
9
public/index.php
Normal file
9
public/index.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
335
public/index_bckp.html
Normal file
335
public/index_bckp.html
Normal file
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AI Agent</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Markdown + Sanitizer -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--panel: #020617;
|
||||
--border: #334155;
|
||||
--text: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--accent: #2563eb;
|
||||
--user: #1e40af;
|
||||
--assistant: #020617;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--user);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message.assistant .bubble {
|
||||
background: var(--assistant);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bubble.loader {
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.bubble p { margin: 0.4rem 0; }
|
||||
.bubble ul { padding-left: 1.2rem; }
|
||||
.bubble code {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.bubble pre {
|
||||
background: #020617;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
padding: 0.75rem;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.abort { background: var(--danger); }
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>mitho AI Agent</h1>
|
||||
<div class="spacer"></div>
|
||||
<button id="clear" class="secondary">Diesen Chat löschen</button>
|
||||
</div>
|
||||
|
||||
<div id="chat" class="chat"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="prompt" placeholder="Ask something…">gebe eine lister alle leistungen von mitho aus</textarea>
|
||||
<button id="send">Send</button>
|
||||
<button id="abort" class="abort" disabled>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const chatEl = document.getElementById('chat');
|
||||
const promptEl = document.getElementById('prompt');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const abortBtn = document.getElementById('abort');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
|
||||
let abort = false;
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
|
||||
function renderMarkdown(text) {
|
||||
console.log(text,marked.parse(text))
|
||||
return DOMPurify.sanitize(marked.parse(text));
|
||||
}
|
||||
|
||||
function renderStreaming(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function normalizeMarkdown(text) {
|
||||
return text
|
||||
|
||||
// HTML Entities entschärfen
|
||||
.replace(/&/g, '&')
|
||||
|
||||
// Füge Zeilenumbrüche vor und nach Markdown-Überschriften (###, #### etc.)
|
||||
.replace(/\s*(#{1,6}.*?)\s*(?=#+|$)/g, '\n\n$1\n\n')
|
||||
|
||||
// Füge Zeilenumbrüche vor und nach Listenpunkten
|
||||
.replace(/\s*-\s+/g, '\n- ')
|
||||
|
||||
// Füge Zeilenumbrüche vor horizontalen Linien (---)
|
||||
.replace(/\s*---+\s*/g, '\n\n---\n\n')
|
||||
|
||||
// Entferne mehrfach aufeinanderfolgende Leerzeilen
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Entferne führende und nachfolgende Leerzeichen
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function addMessage(role, html = '', extra = '') {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'message ' + role;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble ' + extra;
|
||||
bubble.innerHTML = html;
|
||||
|
||||
msg.appendChild(bubble);
|
||||
chatEl.appendChild(msg);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function addLoader() {
|
||||
return addMessage('assistant', 'AI is typing…', 'loader');
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/history');
|
||||
if (!res.ok) return;
|
||||
const messages = await res.json();
|
||||
messages.forEach(m =>
|
||||
addMessage(m.role, renderMarkdown(m.text))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.addEventListener('load', loadHistory);
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (!prompt) return;
|
||||
|
||||
addMessage('user', renderMarkdown(prompt));
|
||||
promptEl.value = '';
|
||||
|
||||
const bubble = addLoader();
|
||||
let raw = '';
|
||||
let first = true;
|
||||
|
||||
abort = false;
|
||||
sendBtn.disabled = true;
|
||||
abortBtn.disabled = false;
|
||||
clearBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/ask-sse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt })
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (!abort) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
decoder.decode(value, { stream: true })
|
||||
.split('\n')
|
||||
.forEach(line => {
|
||||
if (!line.startsWith('data: ')) return;
|
||||
|
||||
const text = line.slice(6);
|
||||
|
||||
if (text === '[DONE]') {
|
||||
const normalized = normalizeMarkdown(raw);
|
||||
bubble.innerHTML = renderMarkdown(normalized);
|
||||
abort = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (first) {
|
||||
bubble.classList.remove('loader');
|
||||
bubble.innerHTML = '';
|
||||
first = false;
|
||||
}
|
||||
|
||||
raw += text;
|
||||
bubble.innerHTML = renderStreaming(raw);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
bubble.innerHTML += '<br><em>Error occurred.</em>';
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
abortBtn.disabled = true;
|
||||
clearBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
abortBtn.addEventListener('click', () => {
|
||||
abort = true;
|
||||
addMessage('assistant', '<em>[aborted]</em>');
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
await fetch('/history/delete', { method: 'POST' });
|
||||
chatEl.innerHTML = '';
|
||||
addMessage('assistant', '<em>History cleared.</em>');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
136
src/Agent/AgentRunner.php
Normal file
136
src/Agent/AgentRunner.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
use App\Context\ContextService;
|
||||
use App\Context\UrlAnalyzer;
|
||||
use App\Infrastructure\OllamaClient;
|
||||
use App\Knowledge\Retrieval\RetrieverInterface;
|
||||
use Generator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use App\Agent\StreamChunker;
|
||||
|
||||
final readonly class AgentRunner
|
||||
{
|
||||
public function __construct(
|
||||
private PromptBuilder $promptBuilder,
|
||||
private ThinkSuppressor $thinkSuppressor,
|
||||
private ContextService $contextService,
|
||||
private UrlAnalyzer $urlAnalyzer,
|
||||
private RetrieverInterface $retriever,
|
||||
private OllamaClient $ollamaClient,
|
||||
private LoggerInterface $agentLogger,
|
||||
private bool $debug,
|
||||
private bool $logPrompt,
|
||||
private bool $logContext,
|
||||
) {}
|
||||
|
||||
public function run(string $prompt, string $userId): Generator
|
||||
{
|
||||
$prompt = trim($prompt);
|
||||
|
||||
if ($prompt === '') {
|
||||
yield '❌ Empty prompt.';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->agentLogger->info('Agent run started', [
|
||||
'userId' => $userId,
|
||||
]);
|
||||
|
||||
try {
|
||||
// ---------------------------------------------------------
|
||||
// 1) Context strategy
|
||||
// ---------------------------------------------------------
|
||||
$includeFullContext = false;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) Extract URL content (if present)
|
||||
// ---------------------------------------------------------
|
||||
$urlContent = $this->urlAnalyzer->extractContentFromPrompt($prompt);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) Retrieve RAG knowledge
|
||||
// ---------------------------------------------------------
|
||||
$knowledgeChunks = $this->retriever->retrieve($prompt);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4) Build final prompt
|
||||
// ---------------------------------------------------------
|
||||
$finalPrompt = $this->promptBuilder->build(
|
||||
prompt: $prompt,
|
||||
userId: $userId,
|
||||
urlContent: $urlContent,
|
||||
knowledgeChunks: $knowledgeChunks,
|
||||
fullContext: $includeFullContext
|
||||
);
|
||||
|
||||
if ($this->debug && $this->logPrompt) {
|
||||
$this->agentLogger->debug($finalPrompt);
|
||||
}
|
||||
|
||||
if ($this->debug && $this->logContext) {
|
||||
$this->agentLogger->debug('Conversation context snapshot', [
|
||||
'context' => $this->contextService->buildUserContext(
|
||||
$userId,
|
||||
$includeFullContext
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5) Stream tokens from the LLM backend (chunked streaming)
|
||||
// ---------------------------------------------------------
|
||||
$fullOutput = '';
|
||||
$chunker = new StreamChunker();
|
||||
|
||||
foreach ($this->ollamaClient->stream($finalPrompt) as $token) {
|
||||
$cleanToken = $this->thinkSuppressor->filter($token);
|
||||
|
||||
if ($cleanToken === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vollständige Antwort weiter sammeln (für History)
|
||||
$fullOutput .= $cleanToken;
|
||||
|
||||
// ⬇️ Token in Chunker geben
|
||||
$chunk = $chunker->push($cleanToken);
|
||||
if ($chunk !== null) {
|
||||
yield $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// ⬇️ Rest flushen
|
||||
$finalChunk = $chunker->flush();
|
||||
if ($finalChunk !== null) {
|
||||
yield $finalChunk;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 6) Persist conversation history
|
||||
// ---------------------------------------------------------
|
||||
$this->contextService->appendHistory(
|
||||
$userId,
|
||||
$prompt,
|
||||
$fullOutput
|
||||
);
|
||||
|
||||
$this->agentLogger->info('Agent run finished', [
|
||||
'userId' => $userId,
|
||||
'outputLength' => mb_strlen($fullOutput),
|
||||
'contextMode' => 'recent',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->agentLogger->error('Agent run failed', [
|
||||
'userId' => $userId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
yield "\n❌ An internal error occurred while processing the request.";
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/Agent/PromptBuilder.php
Normal file
136
src/Agent/PromptBuilder.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
use App\Context\ContextService;
|
||||
use App\Context\UrlAnalyzer;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class PromptBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContextService $contextService,
|
||||
private readonly UrlAnalyzer $urlAnalyzer,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final prompt string for the LLM.
|
||||
*
|
||||
* @param string $prompt
|
||||
* @param string $userId
|
||||
* @param string $urlContent
|
||||
* @param string[] $knowledgeChunks
|
||||
* @param bool $fullContext
|
||||
*/
|
||||
public function build(
|
||||
string $prompt,
|
||||
string $userId,
|
||||
string $urlContent,
|
||||
array $knowledgeChunks,
|
||||
bool $fullContext = false,
|
||||
): string
|
||||
{
|
||||
$now = (new DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1) SYSTEM INSTRUCTIONS
|
||||
// ------------------------------------------------------------
|
||||
$systemLines = [
|
||||
'You are a conversational AI assistant.',
|
||||
'Respond clearly, precisely, and in context of the ongoing conversation.',
|
||||
'The conversation context is authoritative and must be respected.',
|
||||
'External knowledge is supporting information only.',
|
||||
'If the user asks for contact details such as phone number, email address, postal address or contact person, and the provided context contains such information, answer explicitly with the concrete data.',
|
||||
'Do not omit contact details.',
|
||||
'It is allowed and desired to quote contact data verbatim if it appears in the context.',
|
||||
"Current date and time: {$now}",
|
||||
'',
|
||||
'IMPORTANT FORMATTING RULES:',
|
||||
'- Always answer in valid Markdown.',
|
||||
'- Use headings, lists, and paragraphs where appropriate.',
|
||||
'- Insert line breaks early and often.',
|
||||
'- Never write long paragraphs without newlines.',
|
||||
'- Each list item must start on a new line.',
|
||||
'- Prefer short paragraphs over dense text blocks.',
|
||||
'',
|
||||
'IMPORTANT LANGUAGE RULES:',
|
||||
'- If the user input contains misspellings, silently use the correct canonical terms in your answer.',
|
||||
'- Never mention, explain, or point out spelling mistakes.',
|
||||
'- Do not ask clarifying questions about possible misspellings.',
|
||||
'- Do not repeat or quote misspelled terms from the user input.',
|
||||
'- Always use the correct technical spelling found in the provided context.',
|
||||
'- Answer directly and confidently using always correct canonical terminology.'
|
||||
];
|
||||
|
||||
$systemBlock = "SYSTEM:\n" . implode("\n", $systemLines);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2) CONVERSATION CONTEXT (AUTHORITATIVE)
|
||||
// ------------------------------------------------------------
|
||||
$history = $this->contextService->buildUserContext(
|
||||
userId: $userId,
|
||||
full: $fullContext
|
||||
);
|
||||
|
||||
$contextBlock = '';
|
||||
if ($history !== '') {
|
||||
$contextBlock =
|
||||
"CONVERSATION CONTEXT (authoritative):\n" .
|
||||
"The following messages are the previous turns of this conversation.\n" .
|
||||
"They must be considered when answering the next question.\n\n" .
|
||||
$history;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3) EXTERNAL KNOWLEDGE (SUPPORTING)
|
||||
// ------------------------------------------------------------
|
||||
$knowledgeParts = [];
|
||||
|
||||
if ($knowledgeChunks !== []) {
|
||||
$lines = [];
|
||||
|
||||
foreach ($knowledgeChunks as $i => $chunk) {
|
||||
$n = $i + 1;
|
||||
$lines[] = "[{$n}] {$chunk}";
|
||||
}
|
||||
|
||||
$knowledgeParts[] =
|
||||
"RETRIEVED KNOWLEDGE (supporting):\n" .
|
||||
implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
if ($urlContent !== '') {
|
||||
$knowledgeParts[] =
|
||||
"CONTENT FROM URL (supporting):\n" .
|
||||
$urlContent;
|
||||
}
|
||||
|
||||
$knowledgeBlock = '';
|
||||
if ($knowledgeParts !== []) {
|
||||
$knowledgeBlock = implode("\n\n", $knowledgeParts);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 4) USER QUESTION
|
||||
// ------------------------------------------------------------
|
||||
$userBlock =
|
||||
"USER QUESTION:\n" .
|
||||
$prompt;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 5) FINAL PROMPT ASSEMBLY
|
||||
// ------------------------------------------------------------
|
||||
$blocks = array_filter([
|
||||
$systemBlock,
|
||||
$contextBlock,
|
||||
$knowledgeBlock,
|
||||
$userBlock,
|
||||
]);
|
||||
|
||||
return implode("\n\n", $blocks);
|
||||
}
|
||||
}
|
||||
61
src/Agent/StreamChunker.php
Normal file
61
src/Agent/StreamChunker.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
final class StreamChunker
|
||||
{
|
||||
private string $buffer = '';
|
||||
private bool $insideCodeBlock = false;
|
||||
private int $minChunkSize = 120;
|
||||
|
||||
public function push(string $token): ?string
|
||||
{
|
||||
$this->buffer .= $token;
|
||||
|
||||
if (str_contains($token, '```')) {
|
||||
$this->insideCodeBlock = !$this->insideCodeBlock;
|
||||
}
|
||||
|
||||
if ($this->shouldFlush()) {
|
||||
$out = $this->buffer;
|
||||
$this->buffer = '';
|
||||
return $out;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function flush(): ?string
|
||||
{
|
||||
if ($this->buffer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = $this->buffer;
|
||||
$this->buffer = '';
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function shouldFlush(): bool
|
||||
{
|
||||
if ($this->insideCodeBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_ends_with($this->buffer, "\n\n")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/[.!?]\s$/', $this->buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/\n[-*] .+\n$/', $this->buffer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return mb_strlen($this->buffer) >= $this->minChunkSize;
|
||||
}
|
||||
}
|
||||
88
src/Agent/ThinkSuppressor.php
Normal file
88
src/Agent/ThinkSuppressor.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Agent;
|
||||
|
||||
/**
|
||||
* ThinkSuppressor
|
||||
*
|
||||
* Robust streaming-safe suppressor for internal <think>...</think> sections.
|
||||
*
|
||||
* Key properties:
|
||||
* - Handles token fragmentation (partial tags across tokens)
|
||||
* - Stateful per stream, stateless per request
|
||||
* - Does not buffer full responses
|
||||
* - Deterministic and predictable
|
||||
*/
|
||||
final class ThinkSuppressor
|
||||
{
|
||||
/** Indicates whether the stream is currently inside a <think> block. */
|
||||
private bool $insideThink = false;
|
||||
|
||||
/** Indicates whether the think section has been fully closed. */
|
||||
private bool $thinkSectionCompleted = false;
|
||||
|
||||
/**
|
||||
* Rolling buffer for detecting fragmented tags across tokens.
|
||||
*/
|
||||
private string $rollingBuffer = '';
|
||||
|
||||
/**
|
||||
* Maximum buffer length needed to safely detect tags.
|
||||
*/
|
||||
private int $maxBufferLength = 32;
|
||||
|
||||
/**
|
||||
* Filters a single token from the LLM stream.
|
||||
*
|
||||
* @param string $token Raw token from the LLM
|
||||
* @return string Cleaned token safe for user output
|
||||
*/
|
||||
public function filter(string $token): string
|
||||
{
|
||||
// Append to rolling buffer
|
||||
$this->rollingBuffer .= $token;
|
||||
if (strlen($this->rollingBuffer) > $this->maxBufferLength) {
|
||||
$this->rollingBuffer = substr($this->rollingBuffer, -$this->maxBufferLength);
|
||||
}
|
||||
|
||||
// If think section is already completed, just strip stray closing tags
|
||||
if ($this->thinkSectionCompleted) {
|
||||
return str_replace('</think>', '', $token);
|
||||
}
|
||||
|
||||
// Detect fragmented opening <think> tag
|
||||
if (!$this->insideThink && str_contains($this->rollingBuffer, '<think>')) {
|
||||
$this->insideThink = true;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Detect fragmented closing </think> tag
|
||||
if ($this->insideThink && str_contains($this->rollingBuffer, '</think>')) {
|
||||
$this->insideThink = false;
|
||||
$this->thinkSectionCompleted = true;
|
||||
|
||||
// Emit a single line break after think section ends
|
||||
return "\n";
|
||||
}
|
||||
|
||||
// Suppress all content while inside <think>...</think>
|
||||
if ($this->insideThink) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the suppressor state.
|
||||
* Must be called before starting a new stream.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->insideThink = false;
|
||||
$this->thinkSectionCompleted = false;
|
||||
$this->rollingBuffer = '';
|
||||
}
|
||||
}
|
||||
84
src/Command/AgentCliCommand.php
Normal file
84
src/Command/AgentCliCommand.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Agent\AgentRunner;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* AgentCliCommand
|
||||
*
|
||||
* Interactive CLI interface for the AI agent.
|
||||
* Symfony-native, streaming-first implementation.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Read user input from STDIN
|
||||
* - Stream tokens from the AgentRunner
|
||||
* - Render streamed output to the terminal
|
||||
*
|
||||
* The AgentRunner is the single owner of:
|
||||
* - Think suppression
|
||||
* - Context handling
|
||||
* - Streaming semantics
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'mto:agent:chat',
|
||||
description: 'Start an interactive CLI chat with the AI agent'
|
||||
)]
|
||||
final class AgentCliCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentRunner $agentRunner,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('user-id', InputArgument::OPTIONAL, 'User/session identifier', 'cli');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$userId = (string) $input->getArgument('user-id');
|
||||
|
||||
$io->success('AI Agent CLI started. Press Ctrl+C or type "exit" to quit.');
|
||||
$io->writeln('');
|
||||
|
||||
while (true) {
|
||||
$prompt = $io->ask('Question');
|
||||
|
||||
if ($prompt === null) {
|
||||
// EOF (e.g. piped input ended)
|
||||
$io->writeln('');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$prompt = trim($prompt);
|
||||
|
||||
if ($prompt === '' || strtolower($prompt) === 'exit') {
|
||||
$io->writeln('');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('<info>Answer:</info>');
|
||||
|
||||
foreach ($this->agentRunner->run($prompt, $userId) as $token) {
|
||||
$output->write($token);
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/Command/KnowledgeIngestCommand.php
Normal file
116
src/Command/KnowledgeIngestCommand.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
// src/Command/KnowledgeIngestCommand.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Knowledge\Ingest\KnowledgeIngestService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'mto:agent:knowledge:ingest',
|
||||
description: 'Ingest one or multiple markdown/text documents into file-based knowledge chunks'
|
||||
)]
|
||||
final class KnowledgeIngestCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KnowledgeIngestService $ingest,
|
||||
private readonly string $uploadsDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument(
|
||||
'file',
|
||||
InputArgument::OPTIONAL,
|
||||
'Path to a single .txt/.md file'
|
||||
)
|
||||
->addOption(
|
||||
'all',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Ingest all .md files from the uploads directory'
|
||||
)
|
||||
->addOption(
|
||||
'optimize',
|
||||
'o',
|
||||
InputOption::VALUE_NONE,
|
||||
'Optimize chunks for retrieval quality'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$files = [];
|
||||
$optimize = (bool) $input->getOption('optimize');
|
||||
|
||||
if ($input->getOption('all')) {
|
||||
if (!is_dir($this->uploadsDir)) {
|
||||
$output->writeln('<error>❌ uploads directory not found</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$finder = new Finder();
|
||||
$finder
|
||||
->files()
|
||||
->in($this->uploadsDir)
|
||||
->name('*.md');
|
||||
|
||||
if (!$finder->hasResults()) {
|
||||
$output->writeln('<comment>ℹ️ No .md files found in uploads/</comment>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$files[] = $file->getRealPath();
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'📂 Ingesting %d markdown files from uploads (%s)',
|
||||
count($files),
|
||||
$optimize ? 'optimized' : 'standard'
|
||||
));
|
||||
} else {
|
||||
$file = $input->getArgument('file');
|
||||
|
||||
if (!$file) {
|
||||
$output->writeln('<error>❌ Either provide a file or use --all</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$files[] = (string) $file;
|
||||
}
|
||||
|
||||
$totalWritten = 0;
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
$output->writeln('➡️ Ingesting: ' . $filePath);
|
||||
|
||||
$written = $this->ingest->ingestFile(
|
||||
$filePath,
|
||||
optimize: $optimize
|
||||
);
|
||||
|
||||
$totalWritten += count($written);
|
||||
|
||||
foreach ($written as $chunk) {
|
||||
$output->writeln(' - ' . $chunk);
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('✅ Total written chunks: ' . $totalWritten);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
89
src/Command/VectorIngestCommand.php
Normal file
89
src/Command/VectorIngestCommand.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'mto:agent:vector:ingest',
|
||||
description: 'Builds the FAISS vector index from index.json'
|
||||
)]
|
||||
final class VectorIngestCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $vectorDir,
|
||||
private readonly string $projectDir
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$vectorDir = rtrim($this->vectorDir, '/');
|
||||
|
||||
if (!is_dir($vectorDir)) {
|
||||
$output->writeln('<error>Vector directory not found</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$script = $vectorDir . '/vector_ingest.py';
|
||||
|
||||
if (!is_file($script)) {
|
||||
$output->writeln('<error>vector_ingest.py not found</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Enforce venv usage
|
||||
// -------------------------------------------------
|
||||
$venvPython = $vectorDir . '/.venv/bin/python';
|
||||
|
||||
if (!is_file($venvPython)) {
|
||||
$output->writeln('<error>No Python virtual environment found.</error>');
|
||||
$output->writeln('<comment>Run first:</comment>');
|
||||
$output->writeln('<info> php bin/console mto:agent:vector:install</info>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$knowledgeDir = rtrim($this->projectDir, '/') . '/var/knowledge';
|
||||
|
||||
if (!is_dir($knowledgeDir)) {
|
||||
$output->writeln('<error>Knowledge directory not found:</error>');
|
||||
$output->writeln($knowledgeDir);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Building FAISS vector index…</info>');
|
||||
$output->writeln(sprintf(
|
||||
'<comment>Vector dir:</comment> %s',
|
||||
$vectorDir
|
||||
));
|
||||
$output->writeln(sprintf(
|
||||
'<comment>Knowledge dir:</comment> %s',
|
||||
$knowledgeDir
|
||||
));
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s %s %s %s 2>&1',
|
||||
escapeshellarg($venvPython),
|
||||
escapeshellarg($script),
|
||||
escapeshellarg($vectorDir),
|
||||
escapeshellarg($knowledgeDir)
|
||||
);
|
||||
|
||||
exec($cmd, $out, $exitCode);
|
||||
|
||||
foreach ($out as $line) {
|
||||
$output->writeln($line);
|
||||
}
|
||||
|
||||
return $exitCode === 0
|
||||
? Command::SUCCESS
|
||||
: Command::FAILURE;
|
||||
}
|
||||
}
|
||||
114
src/Command/VectorInstallCommand.php
Normal file
114
src/Command/VectorInstallCommand.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* System requirements (once per environment):
|
||||
* sudo apt update
|
||||
* sudo apt install -y python3-venv python3-pip
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'mto:agent:vector:install',
|
||||
description: 'Creates a Python venv and installs vector dependencies'
|
||||
)]
|
||||
final class VectorInstallCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $vectorDir
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if (!is_dir($this->vectorDir)) {
|
||||
$output->writeln('<error>Vector directory not found</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$vectorDir = rtrim($this->vectorDir, '/');
|
||||
$venvDir = $vectorDir . '/.venv';
|
||||
$venvPython = $venvDir . '/bin/python';
|
||||
|
||||
// -------------------------------------------------
|
||||
// 1) Create venv if missing
|
||||
// -------------------------------------------------
|
||||
if (!is_dir($venvDir)) {
|
||||
$output->writeln('<info>Creating Python virtual environment…</info>');
|
||||
|
||||
$cmd = sprintf(
|
||||
'python3 -m venv %s 2>&1',
|
||||
escapeshellarg($venvDir)
|
||||
);
|
||||
|
||||
exec($cmd, $out, $exitCode);
|
||||
|
||||
foreach ($out as $line) {
|
||||
$output->writeln($line);
|
||||
}
|
||||
|
||||
if ($exitCode !== 0 || !is_file($venvPython)) {
|
||||
$output->writeln('');
|
||||
$output->writeln('<error>Failed to create Python virtual environment.</error>');
|
||||
$output->writeln('<comment>Ensure that python3-venv is installed on the system.</comment>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<info>Using existing Python virtual environment</info>');
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 2) Ensure pip exists inside venv
|
||||
// -------------------------------------------------
|
||||
$cmd = sprintf(
|
||||
'%s -m pip --version 2>&1',
|
||||
escapeshellarg($venvPython)
|
||||
);
|
||||
|
||||
exec($cmd, $out, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$output->writeln('');
|
||||
$output->writeln('<error>The existing virtual environment has no pip.</error>');
|
||||
$output->writeln('<comment>This usually means it was created before python3-pip was installed.</comment>');
|
||||
$output->writeln('<comment>Fix:</comment>');
|
||||
$output->writeln(sprintf('<info> rm -rf %s</info>', $venvDir));
|
||||
$output->writeln('<info> php bin/console mto:agent:vector:install</info>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 3) Install / update dependencies
|
||||
// -------------------------------------------------
|
||||
$output->writeln('<info>Installing vector dependencies…</info>');
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s -m pip install --upgrade faiss-cpu sentence-transformers 2>&1',
|
||||
escapeshellarg($venvPython)
|
||||
);
|
||||
|
||||
exec($cmd, $out, $exitCode);
|
||||
|
||||
foreach ($out as $line) {
|
||||
$output->writeln($line);
|
||||
}
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$output->writeln('<error>Dependency installation failed</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>Vector dependencies installed successfully</info>');
|
||||
$output->writeln(sprintf('<comment>venv:</comment> %s', $venvDir));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
126
src/Context/ContextService.php
Normal file
126
src/Context/ContextService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Context;
|
||||
|
||||
/**
|
||||
* ContextService
|
||||
*
|
||||
* Manages conversational history persistence and retrieval.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Persist completed conversation turns (append-only)
|
||||
* - Provide recent or extended conversation context
|
||||
* - Resolve history storage paths safely
|
||||
*
|
||||
* Non-responsibilities:
|
||||
* - No follow-up detection
|
||||
* - No prompt semantics
|
||||
* - No interpretation of user intent
|
||||
*
|
||||
* Context levels:
|
||||
* - Regular context: last N lines (default)
|
||||
* - Full context: extended history for special cases
|
||||
*/
|
||||
final class ContextService
|
||||
{
|
||||
private string $historyDir;
|
||||
|
||||
/**
|
||||
* Number of lines included in regular context.
|
||||
* Intended for normal conversational continuity.
|
||||
*/
|
||||
private int $maxRegularLines = 20;
|
||||
|
||||
/**
|
||||
* Number of lines included in full context.
|
||||
* Intended for exceptional or diagnostic scenarios.
|
||||
*/
|
||||
private int $maxFullLines = 500;
|
||||
|
||||
public function __construct(
|
||||
string $historyDir,
|
||||
string $projectDir,
|
||||
) {
|
||||
/**
|
||||
* Normalize history directory:
|
||||
* - Allow relative paths in env (e.g. "var/agent-history")
|
||||
* - Always resolve to an absolute path based on project root
|
||||
*/
|
||||
$historyDir = rtrim($historyDir, '/');
|
||||
|
||||
if (!str_starts_with($historyDir, '/')) {
|
||||
$historyDir = rtrim($projectDir, '/') . '/' . ltrim($historyDir, '/');
|
||||
}
|
||||
|
||||
$this->historyDir = $historyDir;
|
||||
|
||||
// Ensure directory exists
|
||||
if (!is_dir($this->historyDir)) {
|
||||
mkdir($this->historyDir, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the conversation context for a given user.
|
||||
*
|
||||
* @param string $userId Stable client identifier
|
||||
* @param bool $full Whether to load extended history
|
||||
*/
|
||||
public function buildUserContext(string $userId, bool $full = false): string
|
||||
{
|
||||
$path = $this->getHistoryPath($userId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($lines === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$maxLines = $full ? $this->maxFullLines : $this->maxRegularLines;
|
||||
$selected = array_slice($lines, -$maxLines);
|
||||
|
||||
return implode("\n", $selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a completed interaction to the user's history.
|
||||
*
|
||||
* Format (append-only):
|
||||
* Question: <user prompt>
|
||||
* <assistant response>
|
||||
*/
|
||||
public function appendHistory(string $userId, string $prompt, string $response): void
|
||||
{
|
||||
$path = $this->getHistoryPath($userId);
|
||||
|
||||
$entry = "Question: {$prompt}\n{$response}\n";
|
||||
file_put_contents($path, $entry, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the complete conversation history for a user.
|
||||
*/
|
||||
public function deleteHistory(string $userId): void
|
||||
{
|
||||
$path = $this->getHistoryPath($userId);
|
||||
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute history file path for a user.
|
||||
*/
|
||||
private function getHistoryPath(string $userId): string
|
||||
{
|
||||
$safeUserId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $userId);
|
||||
|
||||
return $this->historyDir . '/' . $safeUserId . '.txt';
|
||||
}
|
||||
}
|
||||
120
src/Context/UrlAnalyzer.php
Normal file
120
src/Context/UrlAnalyzer.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Context;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* UrlAnalyzer
|
||||
*
|
||||
* Extracts and analyzes URL content from user prompts in a production-safe way.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Detect the first URL inside a prompt
|
||||
* - Fetch remote content with strict limits
|
||||
* - Clean and normalize readable text
|
||||
* - Identify trusted internal domains based on URL host
|
||||
*
|
||||
* Design constraints:
|
||||
* - No framework dependencies
|
||||
* - No prompt or agent logic
|
||||
* - Defensive against slow or large responses
|
||||
*/
|
||||
final class UrlAnalyzer
|
||||
{
|
||||
private int $timeoutSeconds = 20;
|
||||
private int $maxChars = 5000;
|
||||
|
||||
/**
|
||||
* List of trusted internal domains.
|
||||
* Used for marking content as authoritative.
|
||||
*/
|
||||
private array $internalDomains = [
|
||||
'mitho-media.de',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts readable text from the first URL found in a prompt.
|
||||
*
|
||||
* @param string $prompt
|
||||
* @return string Cleaned page text or empty string on failure
|
||||
*/
|
||||
public function extractContentFromPrompt(string $prompt): string
|
||||
{
|
||||
if (!preg_match('~https?://\S+|www\.\S+~i', $prompt, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = $matches[0];
|
||||
if (!str_starts_with($url, 'http')) {
|
||||
$url = 'https://' . $url;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || empty($parts['host'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => $this->timeoutSeconds,
|
||||
'user_agent' => 'mithoAgent/1.0',
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$handle = @fopen($url, 'rb', false, $context);
|
||||
if ($handle === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
$html = '';
|
||||
while (!feof($handle) && strlen($html) < $this->maxChars * 2) {
|
||||
$html .= fread($handle, 1024);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove script and style blocks
|
||||
$html = preg_replace('~<script[^>]*>.*?</script>~is', '', $html) ?? $html;
|
||||
$html = preg_replace('~<style[^>]*>.*?</style>~is', '', $html) ?? $html;
|
||||
|
||||
// Strip remaining HTML and normalize whitespace
|
||||
$text = strip_tags($html);
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
|
||||
return mb_substr(trim($text), 0, $this->maxChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a URL belongs to a trusted internal domain.
|
||||
*
|
||||
* @param string $url
|
||||
* @return bool
|
||||
*/
|
||||
public function isInternalDomainUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = mb_strtolower($parts['host']);
|
||||
|
||||
foreach ($this->internalDomains as $domain) {
|
||||
if ($host === $domain || str_ends_with($host, '.' . $domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
115
src/Controller/AskSseController.php
Normal file
115
src/Controller/AskSseController.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Agent\AgentRunner;
|
||||
use App\Http\ClientIdResolver;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final readonly class AskSseController
|
||||
{
|
||||
public function __construct(
|
||||
private AgentRunner $agentRunner,
|
||||
private ClientIdResolver $clientIdResolver,
|
||||
) {}
|
||||
|
||||
#[Route('/ask-sse', name: 'ask_sse', methods: ['POST'])]
|
||||
public function stream(Request $request): StreamedResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$prompt = trim((string) ($data['prompt'] ?? ''));
|
||||
|
||||
$cookieResponse = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $cookieResponse);
|
||||
|
||||
return new StreamedResponse(
|
||||
function () use ($prompt, $clientId, $cookieResponse): void {
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Disable all PHP output buffering
|
||||
// ---------------------------------------------------------
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Forward cookies
|
||||
// ---------------------------------------------------------
|
||||
foreach ($cookieResponse->headers->getCookies() as $cookie) {
|
||||
header('Set-Cookie: ' . $cookie, false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// SSE prelude
|
||||
// ---------------------------------------------------------
|
||||
echo "retry: 3000\n\n";
|
||||
flush();
|
||||
|
||||
if ($prompt === '') {
|
||||
$this->sendEvent('error', 'Empty prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 🔥 FIXED: Sende Chunks direkt (behält \n!)
|
||||
// ---------------------------------------------------------
|
||||
foreach ($this->agentRunner->run($prompt, $clientId) as $chunk) {
|
||||
// Normalize line endings
|
||||
$chunk = str_replace(["\r\n", "\r"], "\n", $chunk);
|
||||
|
||||
// Sende Chunk direkt mit \n
|
||||
$this->sendData($chunk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Signal completion
|
||||
// ---------------------------------------------------------
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
},
|
||||
200,
|
||||
[
|
||||
'Content-Type' => 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
'Connection' => 'keep-alive',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXED: Behält Markdown-Struktur (\n) bei
|
||||
*
|
||||
* SSE erlaubt mehrere "data:"-Zeilen pro Event.
|
||||
* Jede Zeile wird als separate data-Zeile gesendet.
|
||||
*/
|
||||
private function sendData(string $data): void
|
||||
{
|
||||
// Split by \n und sende jede Zeile einzeln
|
||||
$lines = explode("\n", $data);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
echo 'data: ' . $line . "\n";
|
||||
}
|
||||
|
||||
// Leere Zeile = Ende der SSE-Message
|
||||
echo "\n\n";
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a named SSE event.
|
||||
*/
|
||||
private function sendEvent(string $event, string $data): void
|
||||
{
|
||||
$safe = str_replace(["\r", "\n"], ' ', $data);
|
||||
|
||||
echo "event: {$event}\n";
|
||||
echo "data: {$safe}\n\n";
|
||||
flush();
|
||||
}
|
||||
}
|
||||
127
src/Controller/HistoryController.php
Normal file
127
src/Controller/HistoryController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Context\ContextService;
|
||||
use App\Http\ClientIdResolver;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* HistoryController
|
||||
*
|
||||
* Read-only and destructive endpoints for conversation history.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Expose stored chat history for frontend reload
|
||||
* - Allow explicit deletion of the current client's history
|
||||
*
|
||||
* Identity handling:
|
||||
* - Client identity is resolved exclusively via ClientIdResolver
|
||||
* - No user identifiers are accepted from the request
|
||||
*/
|
||||
final class HistoryController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContextService $contextService,
|
||||
private readonly ClientIdResolver $clientIdResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the full conversation history for the current client
|
||||
* in a frontend-friendly structure.
|
||||
*/
|
||||
#[Route('/history', name: 'chat_history', methods: ['GET'])]
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
// Resolve client ID (cookie-based)
|
||||
$response = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $response);
|
||||
|
||||
$raw = $this->contextService->buildUserContext($clientId, full: true);
|
||||
|
||||
if ($raw === '') {
|
||||
return $this->jsonWithCookies([], $response);
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
$lines = explode("\n", $raw);
|
||||
|
||||
$assistantBuffer = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// User message
|
||||
if (str_starts_with($line, 'Question: ')) {
|
||||
// Flush previous assistant output
|
||||
if ($assistantBuffer !== []) {
|
||||
$messages[] = [
|
||||
'role' => 'assistant',
|
||||
'text' => trim(implode("\n", $assistantBuffer)),
|
||||
];
|
||||
$assistantBuffer = [];
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'role' => 'user',
|
||||
'text' => trim(substr($line, 10)),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant output (can span multiple lines)
|
||||
if (trim($line) !== '') {
|
||||
$assistantBuffer[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush trailing assistant output
|
||||
if ($assistantBuffer !== []) {
|
||||
$messages[] = [
|
||||
'role' => 'assistant',
|
||||
'text' => trim(implode("\n", $assistantBuffer)),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonWithCookies($messages, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the complete conversation history for the current client.
|
||||
*/
|
||||
#[Route('/history/delete', name: 'delete_history', methods: ['POST'])]
|
||||
public function delete(Request $request): JsonResponse
|
||||
{
|
||||
// Resolve client ID (cookie-based)
|
||||
$response = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $response);
|
||||
|
||||
$this->contextService->deleteHistory($clientId);
|
||||
|
||||
return $this->jsonWithCookies(
|
||||
[
|
||||
'status' => 'ok',
|
||||
'message' => 'History deleted',
|
||||
],
|
||||
$response
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to return JSON responses while forwarding cookies.
|
||||
*/
|
||||
private function jsonWithCookies(array $data, Response $cookieResponse): JsonResponse
|
||||
{
|
||||
$json = new JsonResponse($data);
|
||||
|
||||
foreach ($cookieResponse->headers->getCookies() as $cookie) {
|
||||
$json->headers->setCookie($cookie);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
46
src/Http/ClientIdResolver.php
Normal file
46
src/Http/ClientIdResolver.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* ClientIdResolver
|
||||
*
|
||||
* Resolves a stable, anonymous client identifier for browser-based users.
|
||||
* The identifier is stored as an HttpOnly cookie.
|
||||
*/
|
||||
final class ClientIdResolver
|
||||
{
|
||||
private const COOKIE_NAME = 'ai_client_id';
|
||||
|
||||
public function resolve(Request $request, Response $response): string
|
||||
{
|
||||
$clientId = $request->cookies->get(self::COOKIE_NAME);
|
||||
|
||||
if (is_string($clientId) && $clientId !== '') {
|
||||
return $clientId;
|
||||
}
|
||||
|
||||
$clientId = Uuid::v4()->toRfc4122();
|
||||
|
||||
$response->headers->setCookie(
|
||||
new Cookie(
|
||||
name: self::COOKIE_NAME,
|
||||
value: $clientId,
|
||||
expire: strtotime('+1 year'),
|
||||
path: '/',
|
||||
secure: false, // set true in production with HTTPS
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_LAX
|
||||
)
|
||||
);
|
||||
|
||||
return $clientId;
|
||||
}
|
||||
}
|
||||
148
src/Infrastructure/OllamaClient.php
Normal file
148
src/Infrastructure/OllamaClient.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use Generator;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* OllamaClient
|
||||
*
|
||||
* Production-ready streaming client for Ollama-compatible LLM backends.
|
||||
*
|
||||
* Key properties:
|
||||
* - True live streaming (tokens are yielded while the request is running)
|
||||
* - PHP-safe (no yield inside cURL callbacks)
|
||||
* - Works for both HTTP streaming and CLI usage
|
||||
* - Deterministic and resource-safe
|
||||
*
|
||||
* Implementation strategy:
|
||||
* - Use curl_multi_* to keep control of the execution loop
|
||||
* - Accumulate partial chunks into a rolling buffer
|
||||
* - Extract JSON lines incrementally
|
||||
* - Yield tokens immediately when they arrive
|
||||
*/
|
||||
final class OllamaClient
|
||||
{
|
||||
private string $apiUrl;
|
||||
private string $model;
|
||||
private int $timeoutSeconds;
|
||||
|
||||
public function __construct(
|
||||
string $apiUrl,
|
||||
string $model,
|
||||
int $timeoutSeconds,
|
||||
) {
|
||||
$this->apiUrl = $apiUrl;
|
||||
$this->model = $model;
|
||||
$this->timeoutSeconds = $timeoutSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams tokens from the LLM backend in real time.
|
||||
*
|
||||
* @param string $prompt Fully constructed prompt
|
||||
*
|
||||
* @return Generator<string>
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function stream(string $prompt): Generator
|
||||
{
|
||||
$payload = json_encode([
|
||||
'model' => $this->model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => true,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$buffer = '';
|
||||
$done = false;
|
||||
|
||||
$ch = curl_init($this->apiUrl);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('Failed to initialize cURL');
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_WRITEFUNCTION => function ($curl, string $data) use (&$buffer, &$done): int {
|
||||
$buffer .= $data;
|
||||
return strlen($data);
|
||||
},
|
||||
]);
|
||||
|
||||
$mh = curl_multi_init();
|
||||
if ($mh === false) {
|
||||
curl_close($ch);
|
||||
throw new RuntimeException('Failed to initialize cURL multi handle');
|
||||
}
|
||||
|
||||
curl_multi_add_handle($mh, $ch);
|
||||
|
||||
try {
|
||||
do {
|
||||
// Execute the multi handle
|
||||
do {
|
||||
$status = curl_multi_exec($mh, $running);
|
||||
} while ($status === CURLM_CALL_MULTI_PERFORM);
|
||||
|
||||
// Read incoming data from the buffer
|
||||
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||
$line = trim(substr($buffer, 0, $pos));
|
||||
$buffer = substr($buffer, $pos + 1);
|
||||
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$json = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($json['response'])) {
|
||||
yield $json['response'];
|
||||
}
|
||||
|
||||
if (!empty($json['done'])) {
|
||||
$done = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for network activity
|
||||
if ($running) {
|
||||
curl_multi_select($mh, 0.2);
|
||||
}
|
||||
} while ($running && !$done);
|
||||
|
||||
// Flush remaining buffer (edge case)
|
||||
if (!$done && trim($buffer) !== '') {
|
||||
try {
|
||||
$json = json_decode(trim($buffer), true, flags: JSON_THROW_ON_ERROR);
|
||||
if (isset($json['response'])) {
|
||||
yield $json['response'];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
throw new RuntimeException('LLM connection error: ' . $error);
|
||||
}
|
||||
} finally {
|
||||
curl_multi_remove_handle($mh, $ch);
|
||||
curl_multi_close($mh);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/Kernel.php
Normal file
11
src/Kernel.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
58
src/Knowledge/Ingest/ChunkIndexWriter.php
Normal file
58
src/Knowledge/Ingest/ChunkIndexWriter.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// src/Knowledge/Ingest/ChunkIndexWriter.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Ingest;
|
||||
|
||||
final class ChunkIndexWriter
|
||||
{
|
||||
public function __construct(
|
||||
private string $indexPath
|
||||
) {}
|
||||
|
||||
public function add(array $entry): void
|
||||
{
|
||||
$index = $this->load();
|
||||
$index[] = $entry;
|
||||
$this->save($index);
|
||||
}
|
||||
|
||||
private function load(): array
|
||||
{
|
||||
if (!is_file($this->indexPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->indexPath);
|
||||
$data = $json ? json_decode($json, true) : null;
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function save(array $index): void
|
||||
{
|
||||
$dir = dirname($this->indexPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->indexPath,
|
||||
json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
public function hasSourceHash(string $source, string $hash): bool
|
||||
{
|
||||
foreach ($this->load() as $entry) {
|
||||
if (
|
||||
($entry['source'] ?? null) === $source &&
|
||||
($entry['sourceHash'] ?? null) === $hash
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
149
src/Knowledge/Ingest/ChunkWriter.php
Normal file
149
src/Knowledge/Ingest/ChunkWriter.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
// src/Knowledge/Ingest/ChunkWriter.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Ingest;
|
||||
|
||||
|
||||
use App\Knowledge\StopWords;
|
||||
|
||||
final class ChunkWriter
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private string $chunksDir,
|
||||
private string $manifestPath,
|
||||
private ChunkIndexWriter $indexWriter,
|
||||
private StopWords $stopWords,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $chunks
|
||||
* @return string[] written filenames
|
||||
*/
|
||||
public function write(string $sourceName, array $chunks, string $sourceHash): array
|
||||
{
|
||||
if (!is_dir($this->chunksDir)) {
|
||||
mkdir($this->chunksDir, 0775, true);
|
||||
}
|
||||
|
||||
$manifest = $this->loadManifest();
|
||||
$written = [];
|
||||
|
||||
$base = $this->safeBase($sourceName);
|
||||
$ts = date('Ymd_His');
|
||||
|
||||
foreach ($chunks as $i => $chunk) {
|
||||
$filename = "{$base}__{$ts}__" . str_pad((string)$i, 4, '0', STR_PAD_LEFT) . ".txt";
|
||||
$path = rtrim($this->chunksDir, '/') . '/' . $filename;
|
||||
|
||||
$header = $this->buildHeader(
|
||||
source: $sourceName,
|
||||
index: $i
|
||||
);
|
||||
|
||||
file_put_contents($path, $header . "\n\n" . $chunk);
|
||||
|
||||
$written[] = $filename;
|
||||
|
||||
$manifest[] = [
|
||||
'file' => $filename,
|
||||
'source' => $sourceName,
|
||||
'index' => $i,
|
||||
'chars' => mb_strlen($chunk),
|
||||
'createdAt' => date('c'),
|
||||
];
|
||||
|
||||
$this->indexWriter->add([
|
||||
'file' => $filename,
|
||||
'source' => $sourceName,
|
||||
'sourceHash' => $sourceHash,
|
||||
'keywords' => $this->extractKeywords($chunk),
|
||||
'chars' => mb_strlen($chunk),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
$this->saveManifest($manifest);
|
||||
return $written;
|
||||
}
|
||||
|
||||
private function safeBase(string $name): string
|
||||
{
|
||||
$name = pathinfo($name, PATHINFO_FILENAME);
|
||||
$name = mb_strtolower($name);
|
||||
$name = preg_replace('/[^a-z0-9\-_]+/u', '-', $name);
|
||||
return trim((string)$name, '-');
|
||||
}
|
||||
|
||||
private function loadManifest(): array
|
||||
{
|
||||
if (!is_file($this->manifestPath)) {
|
||||
return [];
|
||||
}
|
||||
$json = file_get_contents($this->manifestPath);
|
||||
$data = $json ? json_decode($json, true) : null;
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function saveManifest(array $manifest): void
|
||||
{
|
||||
$dir = dirname($this->manifestPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
file_put_contents($this->manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function buildHeader(string $source, int $index): string
|
||||
{
|
||||
return sprintf(
|
||||
'[Quelle: %s | Abschnitt: Chunk %d]',
|
||||
$source,
|
||||
$index + 1
|
||||
);
|
||||
}
|
||||
|
||||
private function extractKeywords(string $text): array
|
||||
{
|
||||
// 1) Lowercase
|
||||
$text = mb_strtolower($text);
|
||||
|
||||
// 2) URLs entfernen (sehr wichtig)
|
||||
$text = preg_replace('#https?://\S+#u', ' ', $text);
|
||||
|
||||
// 3) Newlines & Tabs → Space
|
||||
$text = str_replace(["\r", "\n", "\t"], ' ', $text);
|
||||
|
||||
// 4) Trennzeichen → Space (NICHT löschen!)
|
||||
$text = preg_replace('/[\/\.\,\:\;\-\_\(\)\[\]\{\}]/u', ' ', $text);
|
||||
|
||||
// 5) Alles andere raus
|
||||
$text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text);
|
||||
|
||||
// 6) Whitespace normalisieren
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
$text = trim($text);
|
||||
|
||||
// 7) Wörter extrahieren
|
||||
$words = explode(' ', $text);
|
||||
|
||||
// 8) Filtern + deduplizieren
|
||||
$keywords = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (mb_strlen($word) < 4) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($word, $this->stopWords->getStopWords() ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
$keywords[] = $word;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_slice($keywords, 0, 25)));
|
||||
}
|
||||
}
|
||||
37
src/Knowledge/Ingest/DocumentLoader.php
Normal file
37
src/Knowledge/Ingest/DocumentLoader.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// src/Knowledge/Ingest/DocumentLoader.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Ingest;
|
||||
|
||||
final class DocumentLoader
|
||||
{
|
||||
public function load(string $path): string
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
throw new \RuntimeException("File not found: {$path}");
|
||||
}
|
||||
|
||||
$ext = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return match ($ext) {
|
||||
'txt', 'md' => $this->loadText($path),
|
||||
|
||||
// später:
|
||||
// 'pdf' => $this->loadPdf($path),
|
||||
// 'docx' => $this->loadDocx($path),
|
||||
|
||||
default => throw new \RuntimeException("Unsupported file type: .{$ext}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function loadText(string $path): string
|
||||
{
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException("Could not read file: {$path}");
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
39
src/Knowledge/Ingest/KnowledgeIngestService.php
Normal file
39
src/Knowledge/Ingest/KnowledgeIngestService.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// src/Knowledge/Ingest/KnowledgeIngestService.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Ingest;
|
||||
|
||||
final class KnowledgeIngestService
|
||||
{
|
||||
public function __construct(
|
||||
private DocumentLoader $loader,
|
||||
private SimpleChunker $chunker,
|
||||
private ChunkWriter $writer,
|
||||
private ChunkIndexWriter $indexWriter,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return string[] written chunk filenames */
|
||||
public function ingestFile(string $path, bool $optimize = false): array
|
||||
{
|
||||
$text = $this->loader->load($path);
|
||||
|
||||
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);
|
||||
return $this->writer->write($sourceName, $chunks, $sourceHash);
|
||||
}
|
||||
}
|
||||
146
src/Knowledge/Ingest/SimpleChunker.php
Normal file
146
src/Knowledge/Ingest/SimpleChunker.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
// src/Knowledge/Ingest/SimpleChunker.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Ingest;
|
||||
|
||||
final class SimpleChunker
|
||||
{
|
||||
public function __construct(
|
||||
private int $maxWords = 180,
|
||||
private int $overlapWords = 30
|
||||
) {}
|
||||
|
||||
/** @return string[] */
|
||||
public function chunk(string $text): array
|
||||
{
|
||||
$text = $this->normalize($text);
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split into tokens: words + whitespace preserved
|
||||
$tokens = preg_split(
|
||||
'/(\s+)/u',
|
||||
$text,
|
||||
-1,
|
||||
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
|
||||
);
|
||||
|
||||
if (!$tokens) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build word index → token index mapping
|
||||
$wordTokenIndexes = [];
|
||||
foreach ($tokens as $i => $token) {
|
||||
if (!preg_match('/^\s+$/u', $token)) {
|
||||
$wordTokenIndexes[] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
$totalWords = count($wordTokenIndexes);
|
||||
if ($totalWords === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$chunks = [];
|
||||
$wordPos = 0;
|
||||
|
||||
while ($wordPos < $totalWords) {
|
||||
$wordEnd = min($wordPos + $this->maxWords, $totalWords);
|
||||
|
||||
$tokenStart = $wordTokenIndexes[$wordPos];
|
||||
$tokenEnd = $wordTokenIndexes[$wordEnd - 1] + 1;
|
||||
|
||||
// Intelligent cut (sentence / paragraph aware)
|
||||
$tokenEnd = $this->adjustCutToBoundary($tokens, $tokenStart, $tokenEnd);
|
||||
|
||||
$chunk = trim(implode('', array_slice(
|
||||
$tokens,
|
||||
$tokenStart,
|
||||
$tokenEnd - $tokenStart
|
||||
)));
|
||||
|
||||
if ($chunk !== '') {
|
||||
$chunks[] = $chunk;
|
||||
}
|
||||
|
||||
if ($wordEnd >= $totalWords) {
|
||||
break;
|
||||
}
|
||||
|
||||
$wordPos = max(0, $wordEnd - $this->overlapWords);
|
||||
}
|
||||
|
||||
return $this->dedupe($chunks);
|
||||
}
|
||||
|
||||
private function normalize(string $text): string
|
||||
{
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = preg_replace("/[ \t]+/u", " ", $text);
|
||||
$text = preg_replace("/\n{3,}/u", "\n\n", $text);
|
||||
|
||||
return trim((string) $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cut backwards to a natural boundary if possible.
|
||||
* Rules:
|
||||
* - Never cut inside markdown list items
|
||||
* - Sentence end only if followed by a line break
|
||||
* - Paragraph breaks always allowed
|
||||
*/
|
||||
private function adjustCutToBoundary(array $tokens, int $start, int $end): int
|
||||
{
|
||||
// Detect markdown list context (e.g. "- Foo: Bar")
|
||||
$startToken = $tokens[$start] ?? '';
|
||||
if (preg_match('/^- /u', ltrim($startToken))) {
|
||||
// Keep list blocks intact
|
||||
return $end;
|
||||
}
|
||||
|
||||
for ($i = $end - 1; $i > $start; $i--) {
|
||||
|
||||
// Paragraph boundary
|
||||
if ($tokens[$i] === "\n\n") {
|
||||
return $i + 1;
|
||||
}
|
||||
|
||||
// Sentence boundary only if followed by newline
|
||||
if (
|
||||
preg_match('/[.!?]\s*$/u', $tokens[$i]) &&
|
||||
isset($tokens[$i + 1]) &&
|
||||
str_contains($tokens[$i + 1], "\n")
|
||||
) {
|
||||
return $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $end;
|
||||
}
|
||||
|
||||
/** @param string[] $chunks @return string[] */
|
||||
private function dedupe(array $chunks): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$key = mb_strtolower(
|
||||
preg_replace('/\s+/u', ' ', trim($chunk))
|
||||
);
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$out[] = $chunk;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
35
src/Knowledge/KeywordMapper.php
Normal file
35
src/Knowledge/KeywordMapper.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge;
|
||||
|
||||
/**
|
||||
* KeywordMapper
|
||||
*
|
||||
* Expands short or ambiguous prompts into richer semantic variants
|
||||
* before they are passed into retrieval or embedding pipelines.
|
||||
*
|
||||
* This is a direct port of prompt_mapping.py.
|
||||
*/
|
||||
final class KeywordMapper
|
||||
{
|
||||
private array $map = [
|
||||
'ki' => 'künstliche Intelligenz, AI, Projekte, Modelle, Agenten, ki',
|
||||
'shop' => 'Shopware, Onlineshop, Webshop, Commerce-System',
|
||||
'shops' => 'Shopware, Webshops, Verkaufsplattformen',
|
||||
'agentur' => 'Agentur, Firma, Unternehmen, mitho media',
|
||||
'api' => 'Schnittstelle, API, Anbindung, Integration',
|
||||
'plugin' => 'Shopware Plugin, Erweiterung, Modul, Funktion',
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a raw prompt to an expanded semantic variant if applicable.
|
||||
*/
|
||||
public function map(string $prompt): string
|
||||
{
|
||||
$key = mb_strtolower(trim($prompt));
|
||||
|
||||
return $this->map[$key] ?? $prompt;
|
||||
}
|
||||
}
|
||||
87
src/Knowledge/KeywordSimilarity.php
Normal file
87
src/Knowledge/KeywordSimilarity.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge;
|
||||
|
||||
/**
|
||||
* KeywordSimilarity
|
||||
*
|
||||
* Deterministic and fault-tolerant comparison of two keywords.
|
||||
* Returns a similarity score between 0.0 and 1.0.
|
||||
*
|
||||
* Design goals:
|
||||
* - index.json remains unchanged
|
||||
* - comparison logic is intelligent (typos, phonetics)
|
||||
* - no alias or synonym lists
|
||||
* - no LLM dependency
|
||||
*/
|
||||
final class KeywordSimilarity
|
||||
{
|
||||
/**
|
||||
* Compare a query token with an index keyword.
|
||||
*
|
||||
* @param string $queryToken Token from user input
|
||||
* @param string $indexKeyword Keyword from index.json
|
||||
*
|
||||
* @return float Similarity score (0.0 – 1.0)
|
||||
*/
|
||||
public static function compare(string $queryToken, string $indexKeyword): float
|
||||
{
|
||||
$a = self::normalize($queryToken);
|
||||
$b = self::normalize($indexKeyword);
|
||||
|
||||
// Guard: ignore empty or very short tokens
|
||||
if ($a === '' || $b === '' || mb_strlen($a) < 3 || mb_strlen($b) < 3) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// 1. Exact match
|
||||
if ($a === $b) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// 2. Phonetic comparison (metaphone)
|
||||
// Useful for: showpare → shopware, shopvare → shopware
|
||||
if (metaphone($a) === metaphone($b)) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
// 3. Edit distance comparison (only for longer words)
|
||||
if (mb_strlen($a) >= 6 && mb_strlen($b) >= 6) {
|
||||
$distance = levenshtein($a, $b);
|
||||
|
||||
if ($distance === 1) {
|
||||
return 0.9;
|
||||
}
|
||||
|
||||
if ($distance === 2) {
|
||||
return 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// No relevant match
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a keyword to ensure stable comparison.
|
||||
*/
|
||||
private static function normalize(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
|
||||
// Remove non-alphanumeric characters
|
||||
$value = preg_replace('/[^\p{L}\p{N}]/u', '', $value) ?? '';
|
||||
|
||||
// Normalize German umlauts
|
||||
$map = [
|
||||
'ä' => 'ae',
|
||||
'ö' => 'oe',
|
||||
'ü' => 'ue',
|
||||
'ß' => 'ss',
|
||||
];
|
||||
|
||||
return strtr($value, $map);
|
||||
}
|
||||
}
|
||||
42
src/Knowledge/Retrieval/CachedRetriever.php
Normal file
42
src/Knowledge/Retrieval/CachedRetriever.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Retrieval;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
final class CachedRetriever implements RetrieverInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RetrieverInterface $inner,
|
||||
private CacheItemPoolInterface $cache,
|
||||
private int $ttlSeconds = 600 // 10 Minuten
|
||||
) {}
|
||||
|
||||
public function retrieve(string $prompt, int $limit = 3): array
|
||||
{
|
||||
$key = $this->buildCacheKey($prompt, $limit);
|
||||
|
||||
$item = $this->cache->getItem($key);
|
||||
if ($item->isHit()) {
|
||||
return $item->get();
|
||||
}
|
||||
|
||||
$result = $this->inner->retrieve($prompt, $limit);
|
||||
|
||||
$item->set($result);
|
||||
$item->expiresAfter($this->ttlSeconds);
|
||||
$this->cache->save($item);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildCacheKey(string $prompt, int $limit): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($prompt));
|
||||
$normalized = preg_replace('/\s+/u', ' ', $normalized);
|
||||
|
||||
return 'rag_retrieval_' . sha1($normalized . '|' . $limit);
|
||||
}
|
||||
}
|
||||
25
src/Knowledge/Retrieval/ChunkIndexLoader.php
Normal file
25
src/Knowledge/Retrieval/ChunkIndexLoader.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
// src/Knowledge/Retrieval/ChunkIndexLoader.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Retrieval;
|
||||
|
||||
final class ChunkIndexLoader
|
||||
{
|
||||
public function __construct(
|
||||
private string $indexPath
|
||||
) {}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
if (!is_file($this->indexPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = file_get_contents($this->indexPath);
|
||||
$data = $json ? json_decode($json, true) : null;
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
269
src/Knowledge/Retrieval/ChunkKeywordRetriever.php
Normal file
269
src/Knowledge/Retrieval/ChunkKeywordRetriever.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Retrieval;
|
||||
|
||||
use App\Knowledge\StopWords;
|
||||
use App\Knowledge\VectorSearchChunked;
|
||||
use App\Knowledge\KeywordSimilarity;
|
||||
use App\Vector\VectorSearchClient;
|
||||
|
||||
final class ChunkKeywordRetriever implements RetrieverInterface
|
||||
{
|
||||
private const MAX_KEYWORD_CANDIDATES = 200;
|
||||
private const VECTOR_SCORE_THRESHOLD = 0.65;
|
||||
private const VECTOR_TOP_K = 3;
|
||||
|
||||
public function __construct(
|
||||
private VectorSearchChunked $chunkedSearch,
|
||||
private ChunkIndexLoader $indexLoader,
|
||||
private StopWords $stopWords,
|
||||
private VectorSearchClient $vectorClient,
|
||||
private string $chunksDir,
|
||||
private int $maxChunks = 3,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function retrieve(string $prompt, int $limit = null): array
|
||||
{
|
||||
$limit ??= $this->maxChunks;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1) Prompt → search terms
|
||||
// ---------------------------------------------------------
|
||||
$queryTerms = $this->extractTerms($prompt);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) Keyword-based candidate discovery
|
||||
// ---------------------------------------------------------
|
||||
$result = $queryTerms !== []
|
||||
? $this->findCandidateFiles($queryTerms)
|
||||
: ['files' => [], 'canonicalTerms' => []];
|
||||
|
||||
$candidateScores = array_slice(
|
||||
$result['files'],
|
||||
0,
|
||||
self::MAX_KEYWORD_CANDIDATES,
|
||||
true
|
||||
);
|
||||
|
||||
// Canonical replacement
|
||||
$effectiveTerms = array_map(
|
||||
static fn (string $term): string =>
|
||||
$result['canonicalTerms'][$term] ?? $term,
|
||||
$queryTerms
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) Keyword scoring
|
||||
// ---------------------------------------------------------
|
||||
$scored = [];
|
||||
|
||||
foreach ($candidateScores as $file => $similarityScore) {
|
||||
$path = $this->chunksDir . '/' . $file;
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunk = file_get_contents($path);
|
||||
if ($chunk === false || $chunk === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $this->scoreChunk($chunk, $effectiveTerms);
|
||||
if ($score === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scored[$file] = [
|
||||
'chunk' => trim($chunk),
|
||||
'score' => (int) round($score * $similarityScore),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 🔑 EARLY EXIT: Keyword results are sufficient
|
||||
// ---------------------------------------------------------
|
||||
if (\count($scored) >= $limit) {
|
||||
return $this->finalize($scored, $limit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4) Vector retrieval (semantic fallback)
|
||||
// ---------------------------------------------------------
|
||||
$vectorHits = $this->vectorClient->search($prompt, self::VECTOR_TOP_K);
|
||||
|
||||
foreach ($vectorHits as $hit) {
|
||||
if (
|
||||
!isset($hit['chunk_id'], $hit['score']) ||
|
||||
$hit['score'] < self::VECTOR_SCORE_THRESHOLD
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $hit['chunk_id'] . '.txt';
|
||||
$path = $this->chunksDir . '/' . $file;
|
||||
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseScore = $scored[$file]['score'] ?? 0;
|
||||
|
||||
$vectorBoost = (int) round($hit['score'] * 10);
|
||||
|
||||
if ($vectorBoost <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunk = $scored[$file]['chunk']
|
||||
?? trim((string) file_get_contents($path));
|
||||
|
||||
$scored[$file] = [
|
||||
'chunk' => $chunk,
|
||||
'score' => $baseScore + $vectorBoost,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5) Final fallback
|
||||
// ---------------------------------------------------------
|
||||
if ($scored === []) {
|
||||
return $this->fallbackSearch($prompt);
|
||||
}
|
||||
|
||||
return $this->finalize($scored, $limit);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// FINALIZATION
|
||||
// -------------------------------------------------------------
|
||||
private function finalize(array $scored, int $limit): array
|
||||
{
|
||||
uasort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_slice(
|
||||
$this->normalizeResults(
|
||||
array_column($scored, 'chunk')
|
||||
),
|
||||
0,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// INDEX LOGIC
|
||||
// -------------------------------------------------------------
|
||||
private function findCandidateFiles(array $terms): array
|
||||
{
|
||||
$index = $this->indexLoader->load();
|
||||
$files = [];
|
||||
$canonicalTerms = [];
|
||||
|
||||
foreach ($index as $entry) {
|
||||
if (!isset($entry['file'], $entry['keywords'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($terms as $term) {
|
||||
foreach ($entry['keywords'] as $indexKeyword) {
|
||||
$score = KeywordSimilarity::compare($term, $indexKeyword);
|
||||
|
||||
if ($score >= 0.8) {
|
||||
$files[$entry['file']] = max(
|
||||
$files[$entry['file']] ?? 0.0,
|
||||
$score
|
||||
);
|
||||
$canonicalTerms[$term] = $indexKeyword;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'files' => $files,
|
||||
'canonicalTerms' => $canonicalTerms,
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// FALLBACK
|
||||
// -------------------------------------------------------------
|
||||
private function fallbackSearch(string $prompt): array
|
||||
{
|
||||
$chunkedText = trim($this->chunkedSearch->searchAsText($prompt));
|
||||
if ($chunkedText === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice(
|
||||
$this->normalizeResults($this->splitChunks($chunkedText)),
|
||||
0,
|
||||
$this->maxChunks
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SCORING
|
||||
// -------------------------------------------------------------
|
||||
private function scoreChunk(string $chunk, array $terms): int
|
||||
{
|
||||
$content = mb_strtolower($chunk);
|
||||
$score = 0;
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if (
|
||||
!\in_array($term, $this->stopWords->getStopWords(), true) &&
|
||||
str_contains($content, $term)
|
||||
) {
|
||||
$score += mb_strlen($term) >= 10 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UTIL
|
||||
// -------------------------------------------------------------
|
||||
private function extractTerms(string $text): array
|
||||
{
|
||||
$text = mb_strtolower(
|
||||
preg_replace('/[^\p{L}\p{N}\s]/u', '', $text)
|
||||
);
|
||||
|
||||
return array_values(array_filter(
|
||||
explode(' ', $text),
|
||||
static fn (string $w) => mb_strlen($w) > 2
|
||||
));
|
||||
}
|
||||
|
||||
private function splitChunks(string $text): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode("\n\n", $text)),
|
||||
static fn (string $chunk) => $chunk !== ''
|
||||
));
|
||||
}
|
||||
|
||||
private function normalizeResults(array $chunks): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$key = mb_strtolower(preg_replace('/\s+/u', ' ', $chunk));
|
||||
if (!isset($seen[$key])) {
|
||||
$seen[$key] = true;
|
||||
$out[] = $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
11
src/Knowledge/Retrieval/RetrieverInterface.php
Normal file
11
src/Knowledge/Retrieval/RetrieverInterface.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Knowledge\Retrieval;
|
||||
|
||||
interface RetrieverInterface
|
||||
{
|
||||
/**
|
||||
* @return string[] Plain text knowledge chunks
|
||||
*/
|
||||
public function retrieve(string $prompt, int $limit = 3): array;
|
||||
}
|
||||
1863
src/Knowledge/StopWords.php
Normal file
1863
src/Knowledge/StopWords.php
Normal file
File diff suppressed because it is too large
Load Diff
121
src/Knowledge/VectorSearchChunked.php
Normal file
121
src/Knowledge/VectorSearchChunked.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* VectorSearchChunked
|
||||
*
|
||||
* Chunk-based retrieval service for long-form knowledge documents.
|
||||
* This is a lightweight, deterministic runtime reader for
|
||||
* precomputed knowledge chunks.
|
||||
*
|
||||
* Design principles:
|
||||
* - No runtime indexing
|
||||
* - No ML dependencies
|
||||
* - Deterministic and fast
|
||||
* - Hard limits to protect prompt size
|
||||
*
|
||||
* This service is intentionally simple and can later be replaced
|
||||
* by a real vector database without changing the AgentRunner.
|
||||
*/
|
||||
final class VectorSearchChunked
|
||||
{
|
||||
/**
|
||||
* Directory containing chunked knowledge files.
|
||||
*/
|
||||
private string $dataDir = 'var/knowledge/chunks';
|
||||
|
||||
/**
|
||||
* Maximum number of chunks to return.
|
||||
*/
|
||||
private int $maxChunks = 3;
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
)
|
||||
{
|
||||
$this->dataDir = $this->projectDir . '/' . $this->dataDir;
|
||||
}
|
||||
/**
|
||||
* Returns concatenated relevant chunks as plain text.
|
||||
*
|
||||
* @param string $prompt
|
||||
* @return string
|
||||
*/
|
||||
public function searchAsText(string $prompt): string
|
||||
{
|
||||
|
||||
if (!is_dir($this->dataDir)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$promptLower = mb_strtolower($prompt);
|
||||
$keywords = $this->extractKeywords($promptLower);
|
||||
|
||||
if ($keywords === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach (glob($this->dataDir . '/*.txt') as $file) {
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contentLower = mb_strtolower($content);
|
||||
|
||||
if ($this->matchesKeywords($contentLower, $keywords)) {
|
||||
$matches[] = trim($content);
|
||||
}
|
||||
|
||||
if (count($matches) >= $this->maxChunks) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", $matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts simple keywords from the prompt.
|
||||
*
|
||||
* This is a lightweight heuristic replacement for
|
||||
* full vector or embedding-based search.
|
||||
*/
|
||||
private function extractKeywords(string $prompt): array
|
||||
{
|
||||
$words = preg_split('/\W+/u', $prompt, -1, PREG_SPLIT_NO_EMPTY);
|
||||
if ($words === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keywords = [];
|
||||
foreach ($words as $word) {
|
||||
if (mb_strlen($word) >= 4) {
|
||||
$keywords[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keywords));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the content matches at least one keyword.
|
||||
*/
|
||||
private function matchesKeywords(string $content, array $keywords): bool
|
||||
{
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($content, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
55
src/Vector/VectorSearchClient.php
Normal file
55
src/Vector/VectorSearchClient.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Vector;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class VectorSearchClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $vectorDir,
|
||||
private LoggerInterface $agentLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function search(string $query, int $limit = 5): array
|
||||
{
|
||||
$script = rtrim($this->vectorDir, '/') . '/vector_search.py';
|
||||
$this->agentLogger->info("Run vector search script $script");
|
||||
if (!is_file($script)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Determine Python interpreter (venv preferred)
|
||||
// -------------------------------------------------
|
||||
$venvPython = $this->vectorDir . '/.venv/bin/python';
|
||||
$pythonBin = is_file($venvPython) ? $venvPython : 'python3';
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s %s %s %d 2>&1',
|
||||
escapeshellarg($pythonBin),
|
||||
escapeshellarg($script),
|
||||
escapeshellarg($query),
|
||||
$limit
|
||||
);
|
||||
|
||||
exec($cmd, $out, $exitCode);
|
||||
|
||||
if ($exitCode !== 0 || empty($out)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = implode("\n", $out);
|
||||
|
||||
$this->agentLogger->info($json);
|
||||
|
||||
try {
|
||||
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/Vector/vector_ingest.py
Normal file
89
src/Vector/vector_ingest.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Argument handling
|
||||
# ---------------------------------------------------------
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR: Missing arguments (vectorDir, knowledgeDir)")
|
||||
sys.exit(2)
|
||||
|
||||
vector_dir = Path(sys.argv[1]).resolve()
|
||||
knowledge_dir = Path(sys.argv[2]).resolve()
|
||||
|
||||
index_json = knowledge_dir / "index.json"
|
||||
index_out = vector_dir / "vector.index"
|
||||
meta_out = vector_dir / "vector_meta.json"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Dependency checks
|
||||
# ---------------------------------------------------------
|
||||
try:
|
||||
import faiss # noqa
|
||||
except Exception:
|
||||
print("ERROR: Python module 'faiss' not found.")
|
||||
sys.exit(10)
|
||||
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer # noqa
|
||||
except Exception:
|
||||
print("ERROR: Python module 'sentence-transformers' not found.")
|
||||
sys.exit(11)
|
||||
|
||||
import faiss
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# File checks
|
||||
# ---------------------------------------------------------
|
||||
if not index_json.is_file():
|
||||
print(f"ERROR: index.json not found at {index_json}")
|
||||
sys.exit(20)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Load chunks from index.json
|
||||
# ---------------------------------------------------------
|
||||
with open(index_json, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
texts = []
|
||||
ids = []
|
||||
|
||||
for entry in data:
|
||||
if "file" not in entry:
|
||||
continue
|
||||
|
||||
chunk_path = knowledge_dir / "chunks" / entry["file"]
|
||||
if not chunk_path.is_file():
|
||||
continue
|
||||
|
||||
text = chunk_path.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
texts.append(text)
|
||||
ids.append(entry["file"])
|
||||
|
||||
if not texts:
|
||||
print("ERROR: No chunks loaded from index.json")
|
||||
sys.exit(21)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Build vector index
|
||||
# ---------------------------------------------------------
|
||||
model = SentenceTransformer("all-MiniLM-L6-v2")
|
||||
embeddings = model.encode(texts, normalize_embeddings=True)
|
||||
|
||||
dim = embeddings.shape[1]
|
||||
index = faiss.IndexFlatIP(dim)
|
||||
index.add(embeddings)
|
||||
|
||||
faiss.write_index(index, str(index_out))
|
||||
|
||||
with open(meta_out, "w", encoding="utf-8") as f:
|
||||
json.dump(ids, f)
|
||||
|
||||
print(f"Indexed {len(ids)} chunks.")
|
||||
72
src/Vector/vector_search.py
Normal file
72
src/Vector/vector_search.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Argument handling
|
||||
# ---------------------------------------------------------
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR: Missing arguments (query, limit)")
|
||||
sys.exit(2)
|
||||
|
||||
query = sys.argv[1]
|
||||
limit = int(sys.argv[2])
|
||||
|
||||
vector_dir = Path(__file__).resolve().parent
|
||||
index_path = vector_dir / "vector.index"
|
||||
meta_path = vector_dir / "vector_meta.json"
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Dependency checks (controlled)
|
||||
# ---------------------------------------------------------
|
||||
try:
|
||||
import faiss # noqa
|
||||
except Exception:
|
||||
print("ERROR: Python module 'faiss' not found.")
|
||||
sys.exit(10)
|
||||
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer # noqa
|
||||
except Exception:
|
||||
print("ERROR: Python module 'sentence-transformers' not found.")
|
||||
sys.exit(11)
|
||||
|
||||
import faiss
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# File checks
|
||||
# ---------------------------------------------------------
|
||||
if not index_path.is_file() or not meta_path.is_file():
|
||||
print("ERROR: Vector index not found. Run vector ingest first.")
|
||||
sys.exit(20)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Load model and index
|
||||
# ---------------------------------------------------------
|
||||
model = SentenceTransformer("all-MiniLM-L6-v2")
|
||||
query_vec = model.encode([query], normalize_embeddings=True)
|
||||
|
||||
index = faiss.read_index(str(index_path))
|
||||
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
ids = json.load(f)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------
|
||||
scores, indices = index.search(query_vec, limit)
|
||||
|
||||
results = []
|
||||
for score, idx in zip(scores[0], indices[0]):
|
||||
if idx == -1:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"chunk_id": ids[idx],
|
||||
"score": float(score)
|
||||
})
|
||||
|
||||
print(json.dumps(results))
|
||||
79
symfony.lock
Normal file
79
symfony.lock
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"symfony/console": {
|
||||
"version": "5.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "1.22",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
|
||||
},
|
||||
"files": [
|
||||
".env"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "5.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.4",
|
||||
"ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/monolog-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.7",
|
||||
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/monolog.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "5.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "85de1d8ae45b284c3c84b668171d2615049e698f"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user