From 5441fea665fed96d84e7ba97e1ca0480106818da Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 12 Aug 2026 20:02:30 +0200 Subject: [PATCH] fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367) ## Summary Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline and the related module config, CLI, cron, and migration scaffolding. The Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced to a single read-only listing of usage logs plus operator-driven ignore / unignore / accept / reject endpoints gated on the `review_xlvask_usage_order` permission. See `inventory/self-serve-inventory.md` for the full surface map. ## Test plan - [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated pre-existing failure (`BirdControlPlaneActivationTest`, needs `PLENO_REPO_ROOT_FOR_TESTS`). - [x] `php -l` on every modified PHP file -> no syntax errors. - [x] Grep validation -> zero production-code references to removed surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`, `xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`, `runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`, `minimax`, ...). - [ ] Qodana + Tests workflows green on this PR. Co-authored-by: openhands --------- Co-authored-by: openhands --- inventory/self-serve-inventory.md | 95 + scripts/xlvask-automation-migrate.php | 54 - services/nginx/app/classes/minimax.php | 196 -- .../xlvask_automation_policy_service.php | 1020 ------ .../app/classes/xlvask_automation_service.php | 2888 ----------------- .../app/classes/xlvask_autopilot_service.php | 1287 -------- services/nginx/app/cli.php | 4 - services/nginx/app/cron/Cron.php | 15 - .../app/cron/EnsureXLVaskAutomationSchema.php | 71 - .../miniMax/config/miniMax_api_key_c.php | 29 - .../miniMax/config/miniMax_enabled_c.php | 29 - .../nginx/app/modules/miniMax/miniMax_c.php | 28 - .../app/modules/xlvask/AUTOMATION_RUNBOOK.md | 86 - ...k_automatic_order_attachment_enabled_c.php | 52 - ...ask_automatic_order_creation_enabled_c.php | 52 - .../xlvask_minimax_integration_enabled_c.php | 29 - .../xlvask_openai_integration_enabled_c.php | 29 - .../nginx/app/modules/xlvask/cron/tasks.php | 28 - .../modules/xlvask/helpers/xlvask_tasks.php | 72 +- .../20260804_xlvask_ai_auto_policy_v2.php | 27 - .../nginx/app/modules/xlvask/xlvask_c.php | 32 - .../nginx/app/objects/xlvask_usage_logs_o.php | 77 + services/nginx/app/openapi.yaml | 459 +-- .../nginx/app/routes/moduleConfigRoute.php | 38 - .../nginx/app/routes/moduleXLVaskRoute.php | 39 - .../nginx/app/routes/xlvaskUsageLogsRoute.php | 873 ++--- .../app/tests/Api/XLVaskReviewApiTest.php | 153 - .../app/tests/Api/api_coverage_manifest.php | 2 - .../tests/Unit/Cron/CronTaskRegistryTest.php | 4 +- .../XLVaskAutomationMigrateScriptTest.php | 65 - .../XLVask/XLVaskAutomationServiceTest.php | 805 ----- .../XLVask/XLVaskUsageRouteContractTest.php | 291 +- 32 files changed, 675 insertions(+), 8254 deletions(-) create mode 100644 inventory/self-serve-inventory.md delete mode 100755 scripts/xlvask-automation-migrate.php delete mode 100644 services/nginx/app/classes/minimax.php delete mode 100644 services/nginx/app/classes/xlvask_automation_policy_service.php delete mode 100644 services/nginx/app/classes/xlvask_automation_service.php delete mode 100644 services/nginx/app/classes/xlvask_autopilot_service.php delete mode 100644 services/nginx/app/cron/EnsureXLVaskAutomationSchema.php delete mode 100644 services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php delete mode 100644 services/nginx/app/modules/miniMax/config/miniMax_enabled_c.php delete mode 100644 services/nginx/app/modules/miniMax/miniMax_c.php delete mode 100644 services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md delete mode 100644 services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php delete mode 100644 services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php delete mode 100644 services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php delete mode 100644 services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php delete mode 100644 services/nginx/app/modules/xlvask/cron/tasks.php delete mode 100644 services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php delete mode 100644 services/nginx/app/tests/Api/XLVaskReviewApiTest.php delete mode 100644 services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php delete mode 100644 services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php diff --git a/inventory/self-serve-inventory.md b/inventory/self-serve-inventory.md new file mode 100644 index 00000000..f09237ac --- /dev/null +++ b/inventory/self-serve-inventory.md @@ -0,0 +1,95 @@ +# XL Vask Selvvask surface — inventory & simplification plan + +## Scope + +The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask** +view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the +operator-facing review and order-creation flow. + +Out of scope: any other XLVask, plate scanner, customer, or vehicle surface. + +## Files removed + +| Path | Reason | +| --- | --- | +| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline | +| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline | +| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service | +| `services/nginx/app/classes/minimax.php` | MiniMax integration | +| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) | +| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline | +| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) | +| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema | +| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate | +| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate | +| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate | +| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate | +| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper | +| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration | +| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test | +| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test | +| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test | + +## Code changes (kept & simplified) + +| Path | Change | +| --- | --- | +| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function | +| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case | +| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints | +| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route | +| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link | +| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch | +| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` | +| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) | +| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints | +| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion | +| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface | + +## New operator-facing endpoints + +All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's +`allowedHallIds` (all-scope users see every configured scanner hall; own-scope +users see only their group's halls). + +| Method | Path | Permission | Purpose | +| --- | --- | --- | --- | +| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata | +| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) | +| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason | +| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata | +| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` | +| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason | +| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) | + +## Permissions + +The Selvvask surface uses these permissions only: + +- `list_xlvask_usage_orders_own` +- `list_xlvask_usage_orders_all` +- `review_xlvask_usage_order` + +`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`, +`superuser_xlvask_automation_activate` are not referenced anywhere in the +slimmed surface. + +## Persistence model + +`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason` +columns — no migration required for the simplified flow. + +`orders_o::selectByWashId(int|string $WashId)` and +`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are +the only integration points with the order pipeline. + +## Tests + +- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests. +- One pre-existing failure (`BirdControlPlaneActivationTest`) requires + `PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change. + +## Repo scope + +This inventory covers `api`. The `pleno-vue` side has not yet been updated in +this session and will be handled in a follow-up PR. diff --git a/scripts/xlvask-automation-migrate.php b/scripts/xlvask-automation-migrate.php deleted file mode 100755 index 407f7401..00000000 --- a/scripts/xlvask-automation-migrate.php +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env php -connect(); - -if ($command === 'apply') { - if (($argv[2] ?? '') !== '--yes') { - fwrite(STDERR, "Refusing schema mutation without: apply --yes\n"); - exit(2); - } - $status = \classes\xlvask_usage_logs_schema_bootstrap::applyExplicitMigration(); -} else { - $status = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus(); -} - -fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL); -exit((bool)($status['ready'] ?? false) ? 0 : 1); diff --git a/services/nginx/app/classes/minimax.php b/services/nginx/app/classes/minimax.php deleted file mode 100644 index 26947ffe..00000000 --- a/services/nginx/app/classes/minimax.php +++ /dev/null @@ -1,196 +0,0 @@ -config = new miniMax_c(); - } - - public function requireModuleEnabled(): void - { - if (!$this->config->enabled->isTrue()) { - throw new Exception('MiniMax module is not enabled.'); - } - $apiKey = trim((string)$this->config->api_key->getVariableValue()); - if ($apiKey === '') { - throw new Exception('MiniMax API key is not configured.'); - } - } - - /** - * Send a structured JSON text task to MiniMax M3 (Anthropic-messages format). - * - * Returns the parsed JSON object plus `_minimax_response_model` and `_minimax_usage` - * so the autopilot can compare against the resolved model id and track tokens. - * - * @throws Exception - */ - public function jsonTask( - string $schemaName, - string $prompt, - array $payload, - array $schema, - float $temperature = 0.1, - ?string $model = null - ): array { - $this->requireModuleEnabled(); - - // Anthropic-messages uses a single `messages` array, system prompt is separate, - // and structured output goes in `tools` with `input_schema`. - $data = [ - 'model' => $model ?? $this->model, - 'max_tokens' => (int)$this->max_tokens, - 'temperature' => $temperature, - 'system' => $prompt, - 'messages' => [ - [ - 'role' => 'user', - 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ], - ], - 'tools' => [ - [ - 'name' => $schemaName, - 'description' => 'Return the structured decision for the XL Vask automation planner.', - 'input_schema' => $schema, - ], - ], - // Force the model to call the tool — guarantees a structured JSON object back. - 'tool_choice' => ['type' => 'tool', 'name' => $schemaName], - ]; - - $response = $this->sendRequest($data); - return self::parseJsonTaskResponse($response, $schemaName); - } - - public static function parseJsonTaskResponse(array $response, string $expectedToolName): array - { - // Anthropic-messages stop_reason: end_turn | tool_use | max_tokens | stop_sequence - $stopReason = (string)($response['stop_reason'] ?? ''); - if ($stopReason === 'max_tokens') { - throw new minimax_request_exception('MiniMax response was truncated by max_tokens.', true); - } - if (!in_array($stopReason, ['end_turn', 'tool_use'], true)) { - throw new minimax_request_exception('MiniMax response did not complete (stop_reason=' . $stopReason . ').', true); - } - - $toolInput = null; - $toolName = null; - foreach ((array)($response['content'] ?? []) as $block) { - if (($block['type'] ?? null) === 'tool_use') { - $toolName = (string)($block['name'] ?? ''); - $toolInput = (array)($block['input'] ?? []); - break; - } - } - if ($toolInput === null) { - throw new minimax_request_exception('MiniMax completed without a structured tool_use block.', false); - } - if ($toolName !== $expectedToolName) { - throw new minimax_request_exception( - 'MiniMax returned tool "' . $toolName . '", expected "' . $expectedToolName . '".', - false - ); - } - - $resolvedModel = trim((string)($response['model'] ?? '')); - if ($resolvedModel === '') { - throw new minimax_request_exception('MiniMax response omitted the resolved model.', false); - } - $usage = (array)($response['usage'] ?? []); - $inputTokens = max(0, (int)($usage['input_tokens'] ?? 0)); - $outputTokens = max(0, (int)($usage['output_tokens'] ?? 0)); - // The legacy `_openai_*` aliases keep the autopilot's sanitizeOpenAiResult() working - // unchanged — it reads those keys regardless of which provider produced the result. - return [ - ...$toolInput, - '_minimax_response_model' => $resolvedModel, - '_minimax_usage' => [ - 'input_tokens' => $inputTokens, - 'output_tokens' => $outputTokens, - 'total_tokens' => $inputTokens + $outputTokens, - 'service_tier' => '', - ], - '_openai_response_model' => $resolvedModel, - '_openai_usage' => [ - 'input_tokens' => $inputTokens, - 'output_tokens' => $outputTokens, - 'total_tokens' => $inputTokens + $outputTokens, - 'service_tier' => '', - ], - ]; - } - - /** - * @throws Exception - */ - private function sendRequest(array $data): array - { - $this->requireModuleEnabled(); - $curl = curl_init($this->api_url); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curl, CURLOPT_POST, true); - curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($curl, CURLOPT_TIMEOUT, 60); - // MiniMax uses Anthropic-style auth headers - curl_setopt($curl, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'x-api-key: ' . $this->config->api_key->getVariableValue(), - 'anthropic-version: 2023-06-01', - ]); - curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data)); - $response = curl_exec($curl); - if (curl_errno($curl)) { - $curlCode = curl_errno($curl); - curl_close($curl); - throw new minimax_request_exception( - 'MiniMax transport failed.', - in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true) - ); - } - $httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - curl_close($curl); - $responseData = json_decode($response, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new minimax_request_exception('MiniMax returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus); - } - if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) { - throw new minimax_request_exception('MiniMax request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus); - } - return $responseData; - } -} diff --git a/services/nginx/app/classes/xlvask_automation_policy_service.php b/services/nginx/app/classes/xlvask_automation_policy_service.php deleted file mode 100644 index 646dec6d..00000000 --- a/services/nginx/app/classes/xlvask_automation_policy_service.php +++ /dev/null @@ -1,1020 +0,0 @@ -readinessReadOnly($dateFrom, $dateTo, $hallIds); - $policy = (array)($readiness['policy'] ?? []); - $blocked = (array)($readiness['blocked_reasons'] ?? []); - $executeEnabled = in_array((string)($policy['effective_stage'] ?? 'off'), [ - 'ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped', - ], true) && !(bool)($policy['halted'] ?? false) && (bool)($readiness['ready'] ?? false); - - return [ - 'effective_stage' => (string)($policy['effective_stage'] ?? 'halted'), - 'allowed_modes' => ['dry_run', 'replay', ...($executeEnabled ? ['execute'] : [])], - 'effective_action_sources' => $executeEnabled - && ((bool)($policy['attach_enabled'] ?? false) || (bool)($policy['create_enabled'] ?? false)) - ? ['openai'] - : [], - 'blocked_reasons' => $blocked, - 'readiness' => $readiness, - 'active_run' => $this->firstActiveRunReadOnly($hallIds), - ]; - } - - public function readinessReadOnly(?string $dateFrom = null, ?string $dateTo = null, array $hallIds = []): array - { - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $state = $this->policyStateReadOnly(); - $calibrations = $this->activeCalibrationSnapshotReadOnly(); - $budgets = $this->budgetSnapshotReadOnly($hallIds, $state); - $runtime = $this->runtimeConfigReadOnly(); - $activeRun = $this->firstActiveRunReadOnly($hallIds); - $scopeValid = self::scopeDatesAreValid($dateFrom, $dateTo); - $eligibleCounts = $scopeValid - ? $this->eligibleCountsReadOnly($dateFrom, $dateTo, $hallIds) - : ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; - $blocked = []; - $migration = xlvask_usage_logs_schema_bootstrap::migrationStatus(); - $stage = $state === null ? 'off' : ((bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off')); - - if (!$migration['ready']) { - $blocked[] = 'automation_schema_not_ready'; - } - if (!$scopeValid) { - $blocked[] = 'invalid_invoice_period_scope'; - } - if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) { - $blocked[] = 'wash_id_uniqueness_not_ready'; - } - if (!$runtime['xlvask_enabled']) { - $blocked[] = 'xlvask_module_disabled'; - } - if (!$runtime['openai_enabled']) { - $blocked[] = 'openai_disabled'; - } - if ($state !== null && !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) { - $blocked[] = 'planner_identity_changed'; - } - $policy = $this->formatPolicy($state, $runtime, $budgets, $calibrations, $identity); - if ($stage === 'halted') { - $blocked[] = 'automation_halted'; - } - - $requiredSegments = []; - if (in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true)) { - $requiredSegments[] = 'openai:attach_order'; - } - if (in_array($stage, ['ai_create_canary', 'verified_capped'], true)) { - $requiredSegments[] = 'openai:create_order'; - } - foreach ($requiredSegments as $segment) { - if (!isset($calibrations[$segment])) { - $blocked[] = 'calibration_missing:' . $segment; - } - } - - $blocked = array_values(array_unique($blocked)); - return [ - 'ready' => $blocked === [], - 'blocked_reasons' => $blocked, - 'policy' => $policy, - 'workers' => [ - 'active_execute_run' => $activeRun, - 'queue_contract' => 'xlvask_autopilot_queue_every_60_seconds', - 'health' => $activeRun === null ? 'idle' : 'active', - ], - 'worker_healthy' => (bool)$migration['ready'] && !$this->hasExpiredRunLeaseReadOnly(), - 'calibrations' => array_values($calibrations), - 'budgets' => $budgets, - 'review_progress' => [ - 'attach_order' => [ - 'reviewed' => (int)$budgets['soak']['reviewed_correct_links'], - 'target' => self::LINK_SOAK_TARGET, - 'since' => $state['attach_activated_at'] ?? null, - ], - 'create_order' => [ - 'reviewed' => (int)$budgets['soak']['reviewed_correct_creations'], - 'target' => self::CREATE_SOAK_TARGET, - 'since' => $state['create_activated_at'] ?? null, - ], - ], - 'planner_identity' => $identity, - 'policy_version' => (string)$identity['policy_version'], - 'model' => (string)$identity['model'], - 'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(), - 'migration' => $migration, - 'eligible_counts' => $eligibleCounts, - ]; - } - - public function activeRunReadOnly(array $hallIds = []): ?array - { - return $this->firstActiveRunReadOnly($hallIds); - } - - public function createPolicyPreview(string $targetStage, string $reason, ?int $actorId): array - { - global $db; - if ($actorId === null) { - throw new Exception('An authenticated administrator is required.'); - } - $targetStage = strtolower(trim($targetStage)); - $reason = mb_substr(trim($reason), 0, 1000); - if (!in_array($targetStage, array_values(array_diff(self::STAGES, ['halted'])), true)) { - throw new Exception('Invalid XL Vask automation policy transition.'); - } - if ($reason === '') { - throw new Exception('A reason is required for the XL Vask automation policy transition.'); - } - - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $this->ensurePolicyState(); - $readiness = $this->readinessReadOnly(); - $this->assertTransitionAllowed($targetStage, $readiness); - $state = (array)$this->policyStateReadOnly(); - $snapshot = $this->policySnapshotForTransition($targetStage, $state, $readiness, $reason); - $id = self::uuidV4(); - $selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot)); - $confirmation = 'APPLY-XLVASK-' . strtoupper(str_replace('_', '-', $targetStage)) . '-' . $id; - $payload = $snapshot + ['confirmation_phrase' => $confirmation]; - $payloadJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($payload)); - $idSql = $db->escape_string($id); - $hashSql = $db->escape_string($selectionHash); - $transitionSql = $db->escape_string($targetStage); - if ($db->query( - "INSERT INTO xlvask_automation_policy_previews - (id, selection_hash, requested_transition, payload_json, created_by, expires_at) - VALUES ('{$idSql}', '{$hashSql}', '{$transitionSql}', '{$payloadJson}', {$actorId}, - DATE_ADD(NOW(), INTERVAL " . self::PREVIEW_TTL_SECONDS . " SECOND))" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask automation policy preview could not be stored.'); - } - - return [ - 'id' => $id, - 'selection_hash' => $selectionHash, - 'confirmation_phrase' => $confirmation, - 'requires_confirmation' => true, - 'expires_at' => date('c', time() + self::PREVIEW_TTL_SECONDS), - 'requested_transition' => $targetStage, - 'reason' => $reason, - 'expected_policy_version' => (int)($state['expected_version'] ?? 0), - 'readiness_snapshot' => $readiness, - ]; - } - - public function applyPolicyPreview(array $input, ?int $actorId): array - { - global $db; - $previewId = trim((string)($input['preview_id'] ?? '')); - $selectionHash = trim((string)($input['selection_hash'] ?? '')); - $confirmation = (string)($input['confirmation_text'] ?? ''); - if ($actorId === null || !preg_match('/^[0-9a-f-]{36}$/i', $previewId) || !preg_match('/^[0-9a-f]{64}$/', $selectionHash)) { - throw new Exception('Invalid XL Vask automation policy preview identifiers.'); - } - - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $this->ensurePolicyState(); - $connection = $db->conn(); - $connection->begin_transaction(); - try { - $idSql = $db->escape_string($previewId); - $result = $db->query("SELECT * FROM xlvask_automation_policy_previews WHERE id = '{$idSql}' FOR UPDATE"); - $preview = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; - if ($preview === null - || (int)$preview['created_by'] !== $actorId - || !hash_equals((string)$preview['selection_hash'], $selectionHash) - || !empty($preview['applied_at']) - || strtotime((string)$preview['expires_at']) <= time()) { - throw new Exception('XL Vask automation policy preview is invalid, expired, or already applied.'); - } - $payload = json_decode((string)$preview['payload_json'], true); - if (!is_array($payload) || !hash_equals((string)($payload['confirmation_phrase'] ?? ''), trim($confirmation))) { - throw new Exception('XL Vask automation policy confirmation text is invalid.'); - } - - $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); - $state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null; - if ($state === null || (int)$state['expected_version'] !== (int)($payload['expected_policy_version'] ?? -1)) { - throw new Exception('XL Vask automation policy changed after preview.'); - } - $transition = (string)$preview['requested_transition']; - $readiness = $this->readinessReadOnly(); - $this->assertTransitionAllowed($transition, $readiness); - $reason = mb_substr(trim((string)($payload['reason'] ?? '')), 0, 1000); - if ($reason === '') { - throw new Exception('The XL Vask automation policy transition reason is missing.'); - } - $currentSnapshot = $this->policySnapshotForTransition($transition, $state, $readiness, $reason); - if (!hash_equals($selectionHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($currentSnapshot)))) { - throw new Exception('XL Vask automation readiness changed after preview.'); - } - - $assignments = $this->assignmentsForTransition($transition, $actorId); - if ($db->query( - 'UPDATE xlvask_automation_policy_state SET ' . implode(', ', $assignments) . - ', expected_version = expected_version + 1 WHERE id = 1 AND expected_version = ' . (int)$state['expected_version'] - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask automation policy could not be updated atomically.'); - } - $this->syncLegacyKillSwitchesForStage($transition); - $this->recordPolicyEvent($transition, $actorId, [ - 'selection_hash' => $selectionHash, - 'reason' => $reason, - ]); - if ($db->query("UPDATE xlvask_automation_policy_previews SET applied_at = NOW() WHERE id = '{$idSql}' AND applied_at IS NULL") === false - || $db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask automation policy preview could not be finalized.'); - } - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - - return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()]; - } - - public function halt(?int $actorId, string $reason): array - { - global $db; - if ($actorId === null) { - throw new Exception('An authenticated administrator is required.'); - } - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $this->ensurePolicyState(); - $connection = $db->conn(); - $connection->begin_transaction(); - try { - $result = $db->query('SELECT expected_version FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); - if ($result === false || $result->num_rows < 1) { - throw new Exception('XL Vask automation policy is unavailable.'); - } - $reason = mb_substr(trim($reason), 0, 1000); - $reasonSql = $reason === '' ? 'NULL' : "'" . $db->escape_string($reason) . "'"; - if ($db->query( - "UPDATE xlvask_automation_policy_state - SET stage = 'halted', halted = 1, attach_enabled = 0, create_enabled = 0, halt_reason = {$reasonSql}, - halted_at = NOW(), halted_by = {$actorId}, expected_version = expected_version + 1 - WHERE id = 1" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask automation could not be halted atomically.'); - } - $this->syncLegacyKillSwitchesForStage('halted'); - $this->recordPolicyEvent('halt', $actorId, [ - 'reason' => $reason === '' ? null : $reason, - 'reason_present' => $reason !== '', - ]); - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()]; - } - - /** Called inside the order mutation transaction; rollback also releases the cap reservation. */ - public function reserveAutomaticAction(array $suggestion, array $context): void - { - global $db; - $action = (string)($suggestion['action'] ?? ''); - if (!in_array($action, ['attach_order', 'create_order'], true)) { - throw new Exception('Unsupported XL Vask automatic action.'); - } - $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); - $state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null; - $identity = xlvask_automation_service::automationIdentityForAutomation(); - if ($state === null || (bool)$state['halted'] - || !hash_equals((string)$state['policy_version'], (string)$identity['policy_version']) - || !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) { - throw new xlvask_automation_control_stop( - 'policy_halted_or_identity_changed', - 'XL Vask automatic actions are halted or the planner identity changed.' - ); - } - if (($action === 'attach_order' && !(bool)$state['attach_enabled']) - || ($action === 'create_order' && (!(bool)$state['create_enabled'] || !(bool)$state['attach_enabled']))) { - throw new xlvask_automation_control_stop( - 'policy_action_disabled', - 'The XL Vask automatic action is not activated by server policy.' - ); - } - if (!hash_equals((string)($suggestion['planner_identity_hash'] ?? ''), (string)$identity['identity_hash'])) { - throw new xlvask_automation_control_stop( - 'planner_identity_changed', - 'The XL Vask suggestion planner identity is stale.' - ); - } - if ((string)($suggestion['source'] ?? '') !== 'openai' - || !hash_equals((string)($suggestion['model'] ?? ''), (string)$identity['model'])) { - throw new xlvask_automation_control_stop( - 'planner_identity_changed', - 'The XL Vask suggestion resolved model does not match the calibrated planner identity.' - ); - } - $segment = (string)($suggestion['source'] ?? '') . ':' . $action; - if (!$this->activeCalibrationMatchesIdentity($segment, (string)$identity['identity_hash'])) { - throw new xlvask_automation_control_stop( - 'calibration_revoked', - 'The XL Vask automatic action lacks an exact active calibration artifact.' - ); - } - - $runtime = $this->runtimeConfigReadOnly(); - if (!$runtime['xlvask_enabled'] || !$runtime['openai_enabled'] - || ($action === 'attach_order' && !$runtime['attachment_config_enabled']) - || ($action === 'create_order' && !$runtime['creation_config_enabled'])) { - throw new xlvask_automation_control_stop( - 'runtime_disabled', - 'The XL Vask automatic action was disabled by runtime configuration.' - ); - } - - $hallId = trim((string)($context['hall_id'] ?? '')); - if ($hallId === '' || strlen($hallId) > 191) { - throw new Exception('The XL Vask automatic action lacks a valid hall scope.'); - } - $budget = $this->budgetCountsForAction($action, $hallId, [$hallId]); - [$dailyCap, $perHallDailyCap] = $action === 'attach_order' - ? [self::ATTACH_DAILY_CAP, self::ATTACH_PER_HALL_DAILY_CAP] - : [self::CREATE_DAILY_CAP, self::CREATE_PER_HALL_DAILY_CAP]; - if ($budget['global_last_24_hours'] >= $dailyCap || $budget['hall_last_24_hours'] >= $perHallDailyCap) { - throw new xlvask_automation_control_stop( - 'budget_exhausted', - 'The XL Vask automatic action rolling budget is exhausted.' - ); - } - if ($action === 'create_order' - && $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null) < self::LINK_SOAK_TARGET) { - throw new xlvask_automation_control_stop( - 'attach_soak_incomplete', - 'Automatic order creation is blocked until the link soak target is complete.' - ); - } - - $suggestionId = (int)($suggestion['id'] ?? 0); - $runSql = isset($suggestion['run_id']) && $suggestion['run_id'] !== null ? (string)(int)$suggestion['run_id'] : 'NULL'; - $sourceSql = $db->escape_string((string)($suggestion['source'] ?? '')); - if ($suggestionId < 1 || $db->query( - "INSERT INTO xlvask_automation_action_events (suggestion_id, run_id, hall_id, action, source, policy_version, planner_identity_hash) - VALUES ({$suggestionId}, {$runSql}, '" . $db->escape_string($hallId) . "', '" . $db->escape_string($action) . "', '{$sourceSql}', '" . - $db->escape_string((string)$identity['policy_version']) . "', '" . $db->escape_string((string)$identity['identity_hash']) . "')" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask automatic action budget could not be reserved atomically.'); - } - } - - public function policyAllowsActionReadOnly(string $action): bool - { - $readiness = $this->readinessReadOnly(); - $policy = (array)($readiness['policy'] ?? []); - if (!(bool)($readiness['ready'] ?? false)) { - return false; - } - if ($action === 'attach_order') { - return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false); - } - if ($action === 'create_order') { - return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false) - && (bool)($policy['create_enabled'] ?? false) - && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) >= self::LINK_SOAK_TARGET; - } - return false; - } - - public function reviewAutomaticActionBySuggestion( - int $suggestionId, - string $outcome, - int $actorId, - bool $joinExistingTransaction = false - ): array - { - global $db; - if ($suggestionId < 1 || $actorId < 1 - || !in_array($outcome, ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'], true)) { - throw new Exception('Invalid XL Vask automatic action review.'); - } - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $connection = $db->conn(); - if (!$joinExistingTransaction) { - $connection->begin_transaction(); - } - try { - $result = $db->query( - "SELECT * FROM xlvask_automation_action_events WHERE suggestion_id = {$suggestionId} FOR UPDATE" - ); - if ($result === false || $result->num_rows < 1) { - if (!$joinExistingTransaction) { - $connection->commit(); - } - return [ - 'suggestion_id' => $suggestionId, - 'automatic_action_reviewed' => false, - 'action_halted' => false, - 'affected_action' => null, - ]; - } - $event = $db->fetch_assoc($result); - if (!empty($event['reviewed_at'])) { - if (self::adjudicationRetryMatches((string)($event['review_outcome'] ?? ''), $outcome)) { - if (!$joinExistingTransaction) { - $connection->commit(); - } - return [ - 'suggestion_id' => $suggestionId, - 'automatic_action_reviewed' => true, - 'outcome' => $outcome, - 'action' => (string)$event['action'], - 'idempotent' => true, - 'action_halted' => $outcome !== 'correct', - 'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null, - ]; - } - throw new Exception('The XL Vask automatic action outcome was already reviewed differently.'); - } - if ($db->query( - "UPDATE xlvask_automation_action_events SET review_outcome = '" . $db->escape_string($outcome) . "', - reviewed_by = {$actorId}, reviewed_at = NOW() - WHERE id = " . (int)$event['id'] . " AND reviewed_at IS NULL" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask automatic action review could not be stored atomically.'); - } - if ($outcome !== 'correct') { - $this->haltActionLatchWithinTransaction((string)$event['action'], $outcome, $actorId); - } - if (!$joinExistingTransaction) { - $connection->commit(); - } - return [ - 'suggestion_id' => $suggestionId, - 'automatic_action_reviewed' => true, - 'outcome' => $outcome, - 'action' => (string)$event['action'], - 'action_halted' => $outcome !== 'correct', - 'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null, - ]; - } catch (Throwable $throwable) { - if (!$joinExistingTransaction) { - $connection->rollback(); - } - throw $throwable; - } - } - - public static function adjudicationRetryMatches(?string $existingOutcome, string $requestedOutcome): bool - { - return $existingOutcome !== null - && $existingOutcome !== '' - && hash_equals($existingOutcome, $requestedOutcome); - } - - public function haltActionForCriticalInvariant(string $action, string $reason): void - { - global $db; - if (!in_array($action, ['attach_order', 'create_order'], true)) { - return; - } - try { - $connection = $db->conn(); - $connection->begin_transaction(); - $this->haltActionLatchWithinTransaction($action, 'critical_invariant:' . mb_substr($reason, 0, 120), 0); - $connection->commit(); - } catch (Throwable $throwable) { - try { - $db->conn()->rollback(); - } catch (Throwable) { - } - try { - $xlvask = new xlvask(); - if ($action === 'attach_order') { - $xlvask->config->automatic_order_attachment_enabled->setVariableValue(false); - } - $xlvask->config->automatic_order_creation_enabled->setVariableValue(false); - } catch (Throwable) { - } - error_log('[xlvask-automation] Failed to persist critical invariant latch.'); - } - } - - private function haltActionLatchWithinTransaction(string $action, string $reason, int $actorId): void - { - global $db; - $stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE'); - if ($stateResult === false || $stateResult->num_rows < 1) { - throw new Exception('XL Vask automation policy is unavailable.'); - } - $reasonSql = $db->escape_string(mb_substr($reason, 0, 1000)); - if ($action === 'attach_order') { - $assignments = "stage = 'advisory', attach_enabled = 0, create_enabled = 0, attach_halt_reason = '{$reasonSql}'"; - $stage = 'advisory'; - } else { - $assignments = "stage = 'ai_attach_verified', create_enabled = 0, create_halt_reason = '{$reasonSql}'"; - $stage = 'ai_attach_verified'; - } - if ($db->query( - "UPDATE xlvask_automation_policy_state SET {$assignments}, expected_version = expected_version + 1 WHERE id = 1" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask action latch could not be halted atomically.'); - } - $segment = 'openai:' . $action; - if ($db->query( - "UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW() - WHERE active = 1 AND segment_key = '" . $db->escape_string($segment) . "'" - ) === false) { - throw new Exception('The stale XL Vask calibration could not be invalidated atomically.'); - } - $this->syncLegacyKillSwitchesForStage($stage); - $this->recordPolicyEvent('action_latch_halted', $actorId, [ - 'action' => $action, - 'reason' => $reason, - 'invalidated_calibration_segment' => $segment, - ]); - } - - private function assertTransitionAllowed(string $targetStage, array $readiness): void - { - $policy = (array)($readiness['policy'] ?? []); - $currentStage = (string)($policy['effective_stage'] ?? 'off'); - if (in_array($targetStage, ['off', 'advisory'], true)) { - return; - } - $allowedNext = [ - 'advisory' => 'ai_attach_canary', - 'ai_attach_canary' => 'ai_attach_verified', - 'ai_attach_verified' => 'ai_create_canary', - 'ai_create_canary' => 'verified_capped', - ]; - $blocking = []; - if (($allowedNext[$currentStage] ?? null) !== $targetStage) { - $blocking[] = 'invalid_stage_transition:' . $currentStage . ':' . $targetStage; - } - if (($readiness['workers']['active_execute_run'] ?? null) !== null) { - $blocking[] = 'active_execute_run'; - } - foreach (['automation_schema_not_ready', 'wash_id_uniqueness_not_ready', 'xlvask_module_disabled', 'openai_disabled', 'planner_identity_changed'] as $reason) { - if (in_array($reason, (array)($readiness['blocked_reasons'] ?? []), true)) { - $blocking[] = $reason; - } - } - $calibrationSegments = array_column((array)($readiness['calibrations'] ?? []), 'segment_key'); - if (in_array($targetStage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true) - && !in_array('openai:attach_order', $calibrationSegments, true)) { - $blocking[] = 'calibration_missing:openai:attach_order'; - } - if (in_array($targetStage, ['ai_create_canary', 'verified_capped'], true) - && !in_array('openai:create_order', $calibrationSegments, true)) { - $blocking[] = 'calibration_missing:openai:create_order'; - } - if ($targetStage === 'ai_attach_verified' - && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) { - $blocking[] = 'attach_soak_incomplete'; - } - if ($targetStage === 'ai_create_canary' - && (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) { - $blocking[] = 'attach_soak_incomplete'; - } - if ($targetStage === 'verified_capped' - && (int)($readiness['review_progress']['create_order']['reviewed'] ?? 0) < self::CREATE_SOAK_TARGET) { - $blocking[] = 'create_soak_incomplete'; - } - if ($blocking !== []) { - throw new Exception('XL Vask policy transition is blocked: ' . implode(', ', array_unique($blocking))); - } - } - - private function assignmentsForTransition(string $targetStage, int $actorId): array - { - global $db; - $stageSql = "stage = '" . $targetStage . "'"; - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $identityAssignments = [ - "policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'", - "planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'", - ]; - return match ($targetStage) { - 'off', 'advisory' => [ - $stageSql, ...$identityAssignments, 'halted = 0', 'attach_enabled = 0', 'create_enabled = 0', - 'halt_reason = NULL', 'halted_at = NULL', 'halted_by = NULL', - ], - 'ai_attach_canary' => [ - $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0', - 'attach_activated_at = NOW()', "attach_activated_by = {$actorId}", 'attach_halt_reason = NULL', - ], - 'ai_attach_verified' => [ - $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0', 'attach_halt_reason = NULL', - ], - 'ai_create_canary' => [ - $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1', - 'create_activated_at = NOW()', "create_activated_by = {$actorId}", 'create_halt_reason = NULL', - ], - 'verified_capped' => [ - $stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1', 'create_halt_reason = NULL', - ], - default => throw new Exception('Invalid XL Vask policy transition.'), - }; - } - - private function policySnapshotForTransition(string $transition, array $state, array $readiness, string $reason): array - { - return [ - 'requested_transition' => $transition, - 'reason' => $reason, - 'expected_policy_version' => (int)($state['expected_version'] ?? 0), - 'planner_identity_hash' => (string)($readiness['planner_identity']['identity_hash'] ?? ''), - 'wash_id_uniqueness_ready' => (bool)($readiness['wash_id_uniqueness_ready'] ?? false), - 'active_execute_run_id' => $readiness['workers']['active_execute_run']['id'] ?? null, - 'calibration_artifacts' => array_map(static fn(array $item): array => [ - 'segment_key' => (string)$item['segment_key'], - 'artifact_hash' => (string)$item['artifact_hash'], - 'automation_identity_hash' => (string)($item['automation_identity_hash'] ?? ''), - ], (array)($readiness['calibrations'] ?? [])), - 'review_progress' => $readiness['review_progress'] ?? [], - ]; - } - - private function ensurePolicyState(): void - { - global $db; - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $db->query( - "INSERT IGNORE INTO xlvask_automation_policy_state - (id, policy_version, planner_identity_hash, stage, halted, attach_enabled, create_enabled) - VALUES (1, '" . $db->escape_string((string)$identity['policy_version']) . "', '" . - $db->escape_string((string)$identity['identity_hash']) . "', 'off', 0, 0, 0)" - ); - } - - private function policyStateReadOnly(): ?array - { - global $db; - try { - $result = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 LIMIT 1'); - return $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; - } catch (Throwable) { - return null; - } - } - - private function activeCalibrationSnapshotReadOnly(): array - { - global $db; - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $verified = []; - try { - $result = $db->query( - "SELECT * FROM xlvask_automation_calibrations - WHERE active = 1 AND invalidated_at IS NULL - AND policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "' - ORDER BY segment_key" - ); - foreach ($result === false ? [] : $db->fetch_all($result) as $row) { - $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); - if (!is_array($artifact) - || !hash_equals((string)($row['artifact_hash'] ?? ''), hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact))) - || !hash_equals((string)($artifact['automation_identity_hash'] ?? ''), (string)$identity['identity_hash']) - || !hash_equals((string)($artifact['resolved_model'] ?? ''), (string)$identity['model']) - || xlvask_automation_service::classifyCertaintyForAutomation([...$artifact, 'active' => true]) !== 'certain') { - continue; - } - $verified[(string)$row['segment_key']] = [ - 'id' => (int)$row['id'], - 'segment_key' => (string)$row['segment_key'], - 'artifact_hash' => (string)$row['artifact_hash'], - 'automation_identity_hash' => (string)$artifact['automation_identity_hash'], - 'resolved_model' => (string)$artifact['resolved_model'], - 'precision_value' => (float)$row['precision_value'], - 'wilson_lower_bound' => (float)$row['wilson_lower_bound'], - 'holdout_examples' => (int)$row['holdout_examples'], - 'segment_examples' => (int)$row['segment_examples'], - 'contradictions' => (int)$row['contradictions'], - 'activated_at' => $row['activated_at'] ?? null, - ]; - } - } catch (Throwable) { - return []; - } - return $verified; - } - - private function activeCalibrationMatchesIdentity(string $segment, string $identityHash): bool - { - $calibrations = $this->activeCalibrationSnapshotReadOnly(); - return isset($calibrations[$segment]) - && hash_equals((string)$calibrations[$segment]['automation_identity_hash'], $identityHash); - } - - private function firstActiveRunReadOnly(array $hallIds = []): ?array - { - global $db; - try { - $result = $db->query( - "SELECT id, mode, status, phase, processed, total, created_by, created_at, started_at, scope_hall_ids_json - FROM xlvask_autopilot_runs - WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait') - ORDER BY created_at ASC, id ASC LIMIT 100" - ); - if ($result === false || $result->num_rows < 1) { - return null; - } - foreach ($db->fetch_all($result) as $row) { - $scope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true); - $scope = is_array($scope) ? array_map('strval', $scope) : []; - if ($hallIds !== [] && array_intersect($scope, array_map('strval', $hallIds)) === []) { - continue; - } - return [ - 'id' => (int)$row['id'], 'mode' => (string)$row['mode'], 'status' => (string)$row['status'], - 'phase' => (string)$row['phase'], 'processed' => (int)$row['processed'], 'total' => (int)$row['total'], - 'created_at' => $row['created_at'] ?? null, 'started_at' => $row['started_at'] ?? null, - ]; - } - return null; - } catch (Throwable) { - return null; - } - } - - private function hasExpiredRunLeaseReadOnly(): bool - { - global $db; - try { - $result = $db->query( - "SELECT id FROM xlvask_autopilot_runs - WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at < NOW() LIMIT 1" - ); - return $result === false || $result->num_rows > 0; - } catch (Throwable) { - return true; - } - } - - private function eligibleCountsReadOnly(?string $dateFrom, ?string $dateTo, array $hallIds): array - { - global $db; - $hallIds = array_values(array_unique(array_filter(array_map( - static fn(mixed $value): string => trim((string)$value), - $hallIds - ), static fn(string $value): bool => $value !== '' && strlen($value) <= 191))); - if ($hallIds === []) { - return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; - } - try { - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $state = $this->policyStateReadOnly(); - $runtime = $this->runtimeConfigReadOnly(); - $stage = $state === null || (bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off'); - $attachEnabled = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true) - && (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled']; - $createEnabled = in_array($stage, ['ai_create_canary', 'verified_capped'], true) - && (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled']; - if (!$attachEnabled && !$createEnabled) { - return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; - } - $allowedActions = array_filter([ - $attachEnabled ? 'attach_order' : null, - $createEnabled ? 'create_order' : null, - ]); - $where = [ - "u.resolution_state = 'needs_review'", "u.import_state <> 'invalid'", 'u.ignored_at IS NULL', - 'u.FinishStatus = 1', 'u.source_hash IS NOT NULL', 'u.source_hash <> \'\'', - 'u.source_observation_count >= 2', 'u.source_observed_at IS NOT NULL', - 'u.source_stable_since <= DATE_SUB(NOW(), INTERVAL 6 HOUR)', - "s.status = 'suggested'", "s.source = 'openai'", "s.certainty = 'certain'", - "s.policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'", - "s.planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'", - "s.model = '" . $db->escape_string((string)$identity['model']) . "'", - 's.expected_version = u.expected_version', - 's.input_hash = u.source_hash', - "s.action IN ('" . implode("','", array_map([$db, 'escape_string'], $allowedActions)) . "')", - 'NOT EXISTS (SELECT 1 FROM xlvask_automation_suggestions newer WHERE newer.usage_log_id = s.usage_log_id AND newer.id > s.id)', - 'u.HallId IN (' . implode(',', array_map( - static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'", - $hallIds - )) . ')', - ]; - if ($dateFrom !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) { - $where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') >= '" . $db->escape_string($dateFrom) . " 00:00:00'"; - } - if ($dateTo !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { - $where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') <= '" . $db->escape_string($dateTo) . " 23:59:59'"; - } - $row = $db->fetch_assoc($db->query( - "SELECT SUM(s.action = 'attach_order') attach_order, - SUM(s.action = 'create_order') create_order - FROM xlvask_usage_logs u - INNER JOIN xlvask_automation_suggestions s ON s.usage_log_id = u.id - WHERE " . implode(' AND ', $where) - )); - $attach = (int)($row['attach_order'] ?? 0); - $create = (int)($row['create_order'] ?? 0); - return ['attach_order' => $attach, 'create_order' => $create, 'total' => $attach + $create]; - } catch (Throwable) { - return ['attach_order' => 0, 'create_order' => 0, 'total' => 0]; - } - } - - public static function scopeDatesAreValid(?string $dateFrom, ?string $dateTo): bool - { - $validDate = static function (?string $value): bool { - if ($value === null) { - return true; - } - $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); - return $parsed !== false && $parsed->format('Y-m-d') === $value; - }; - return $validDate($dateFrom) && $validDate($dateTo) - && ($dateFrom === null || $dateTo === null || $dateFrom <= $dateTo); - } - - private function budgetSnapshotReadOnly(array $hallIds, ?array $state): array - { - $attach = $this->budgetCountsForAction('attach_order', null, $hallIds); - $create = $this->budgetCountsForAction('create_order', null, $hallIds); - $attachBudget = $attach + [ - 'global_rolling_24h_cap' => self::ATTACH_DAILY_CAP, - 'per_hall_rolling_24h_cap' => self::ATTACH_PER_HALL_DAILY_CAP, - 'remaining_global' => max(0, self::ATTACH_DAILY_CAP - (int)$attach['global_last_24_hours']), - 'remaining_hall' => max(0, self::ATTACH_PER_HALL_DAILY_CAP - (int)$attach['max_hall_last_24_hours']), - ]; - $createBudget = $create + [ - 'global_rolling_24h_cap' => self::CREATE_DAILY_CAP, - 'per_hall_rolling_24h_cap' => self::CREATE_PER_HALL_DAILY_CAP, - 'remaining_global' => max(0, self::CREATE_DAILY_CAP - (int)$create['global_last_24_hours']), - 'remaining_hall' => max(0, self::CREATE_PER_HALL_DAILY_CAP - (int)$create['max_hall_last_24_hours']), - ]; - return [ - 'attach_order' => $attachBudget, - 'create_order' => $createBudget, - 'soak' => [ - 'reviewed_correct_links' => $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null), - 'reviewed_correct_creations' => $this->reviewedCorrectCount('create_order', $state['create_activated_at'] ?? null), - ], - ]; - } - - private function budgetCountsForAction(string $action, ?string $hallId = null, array $visibleHallIds = []): array - { - global $db; - try { - $actionSql = $db->escape_string($action); - $hallSql = $hallId === null ? null : $db->escape_string($hallId); - $visibleHallIds = array_values(array_unique(array_filter(array_map( - static fn(mixed $value): string => trim((string)$value), - $visibleHallIds - ), static fn(string $value): bool => $value !== '' && strlen($value) <= 191))); - $visibleHallWhere = $visibleHallIds === [] ? '' : ' AND hall_id IN (' . implode(',', array_map( - static fn(string $value): string => "'" . $db->escape_string($value) . "'", - $visibleHallIds - )) . ')'; - $row = $db->fetch_assoc($db->query( - "SELECT COUNT(*) global_last_24_hours, - SUM(" . ($hallSql === null ? '0' : "hall_id = '{$hallSql}'") . ") hall_last_24_hours - FROM xlvask_automation_action_events - WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)" - )); - $maxHallRow = $db->fetch_assoc($db->query( - "SELECT COALESCE(MAX(hall_total), 0) max_hall_last_24_hours FROM ( - SELECT hall_id, COUNT(*) hall_total FROM xlvask_automation_action_events - WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR){$visibleHallWhere} - GROUP BY hall_id - ) hall_budgets" - )); - return [ - 'global_last_24_hours' => (int)($row['global_last_24_hours'] ?? 0), - 'hall_last_24_hours' => (int)($row['hall_last_24_hours'] ?? 0), - 'max_hall_last_24_hours' => (int)($maxHallRow['max_hall_last_24_hours'] ?? 0), - ]; - } catch (Throwable) { - return ['global_last_24_hours' => PHP_INT_MAX, 'hall_last_24_hours' => PHP_INT_MAX, 'max_hall_last_24_hours' => PHP_INT_MAX]; - } - } - - private function reviewedCorrectCount(string $action, ?string $since): int - { - global $db; - try { - $actionSql = $db->escape_string($action); - if ($since === null || trim($since) === '') { - return 0; - } - $sinceSql = $db->escape_string($since); - $row = $db->fetch_assoc($db->query( - "SELECT COUNT(*) total FROM xlvask_automation_action_events - WHERE action = '{$actionSql}' AND source = 'openai' - AND review_outcome = 'correct' AND reviewed_at IS NOT NULL - AND created_at >= '{$sinceSql}'" - )); - return (int)($row['total'] ?? 0); - } catch (Throwable) { - return 0; - } - } - - private function runtimeConfigReadOnly(): array - { - try { - $xlvask = new xlvask(); - $openai = new openai(); - return [ - 'xlvask_enabled' => $xlvask->config->enabled->isTrue(), - 'openai_enabled' => $xlvask->config->openai_integration_enabled->isTrue() && $openai->config->enabled->isTrue(), - 'attachment_config_enabled' => $xlvask->config->automatic_order_attachment_enabled->isTrue(), - 'creation_config_enabled' => $xlvask->config->automatic_order_creation_enabled->isTrue(), - ]; - } catch (Throwable) { - return [ - 'xlvask_enabled' => false, 'openai_enabled' => false, - 'attachment_config_enabled' => false, 'creation_config_enabled' => false, - ]; - } - } - - private function syncLegacyKillSwitchesForStage(string $stage): void - { - $xlvask = new xlvask(); - $attach = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true); - $create = in_array($stage, ['ai_create_canary', 'verified_capped'], true); - $xlvask->config->automatic_order_attachment_enabled->setFromAutomationPolicy($attach); - $xlvask->config->automatic_order_creation_enabled->setFromAutomationPolicy($create); - } - - private function formatPolicy(?array $state, array $runtime, array $budgets, array $calibrations, array $identity): array - { - $halted = $state !== null && (bool)($state['halted'] ?? false); - $attachEnabled = !$halted && (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled']; - $createEnabled = $attachEnabled && (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled']; - $stage = $halted ? 'halted' : (string)($state['stage'] ?? 'off'); - return [ - 'policy_version' => (string)$identity['policy_version'], - 'expected_version' => (int)($state['expected_version'] ?? 0), - 'planner_identity_hash' => (string)$identity['identity_hash'], - 'halted' => $halted, - 'halt_reason' => $state['halt_reason'] ?? null, - 'attach_halt_reason' => $state['attach_halt_reason'] ?? null, - 'create_halt_reason' => $state['create_halt_reason'] ?? null, - 'attach_enabled' => $attachEnabled, - 'create_enabled' => $createEnabled, - 'effective_stage' => $stage, - 'runtime_kill_switches' => $runtime, - 'calibration_segments' => array_keys($calibrations), - ]; - } - - private function recordPolicyEvent(string $eventType, int $actorId, array $details): void - { - global $db; - $detailsJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($details)); - if ($db->query( - "INSERT INTO xlvask_automation_policy_events (event_type, actor_id, details_json) - VALUES ('" . $db->escape_string($eventType) . "', {$actorId}, '{$detailsJson}')" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask automation policy event could not be recorded.'); - } - } - - private static function uuidV4(): string - { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } -} diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php deleted file mode 100644 index 45fc5cae..00000000 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ /dev/null @@ -1,2888 +0,0 @@ - 500, - 'standard' => 150, - 'economy' => 50, - ]; - private const DEFAULT_AI_INPUT_USD_PER_1M = 0.50; - private const DEFAULT_AI_OUTPUT_USD_PER_1M = 2.00; - private ?int $runId = null; - private bool $readOnlyEvaluation = false; - /** @var array{timeline:string,batch_size:int,max_cost_usd:?float,input_usd_per_1m:float,output_usd_per_1m:float} */ - private array $aiPolicy = [ - 'timeline' => self::DEFAULT_AI_TIMELINE, - 'batch_size' => self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[self::DEFAULT_AI_TIMELINE], - 'max_cost_usd' => null, - 'input_usd_per_1m' => self::DEFAULT_AI_INPUT_USD_PER_1M, - 'output_usd_per_1m' => self::DEFAULT_AI_OUTPUT_USD_PER_1M, - ]; - /** @var array{requests:int,cache_hits:int,input_tokens:int,output_tokens:int,total_tokens:int,estimated_cost_usd:float,budget_exhausted:bool,stop_reason:?string} */ - private array $aiUsage = [ - 'requests' => 0, - 'cache_hits' => 0, - 'input_tokens' => 0, - 'output_tokens' => 0, - 'total_tokens' => 0, - 'estimated_cost_usd' => 0.0, - 'budget_exhausted' => false, - 'stop_reason' => null, - ]; - - public function __construct() - { - // Read construction must not perform DDL. Mutation entry points bootstrap explicitly. - } - - public function setRunContext(?int $runId): self - { - $this->runId = $runId !== null && $runId > 0 ? $runId : null; - return $this; - } - - public function setReadOnlyEvaluation(bool $readOnly): self - { - $this->readOnlyEvaluation = $readOnly; - return $this; - } - - public function setAiExecutionPolicy(array $policy): self - { - $timeline = strtolower(trim((string)($policy['timeline'] ?? self::DEFAULT_AI_TIMELINE))); - if (!in_array($timeline, self::AI_TIMELINES, true)) { - $timeline = self::DEFAULT_AI_TIMELINE; - } - $defaultBatch = self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[$timeline]; - $batchSize = isset($policy['batch_size']) ? (int)$policy['batch_size'] : $defaultBatch; - $batchSize = max(1, min(500, $batchSize)); - $maxCost = isset($policy['max_cost_usd']) && $policy['max_cost_usd'] !== null - ? max(0.0, (float)$policy['max_cost_usd']) - : null; - $inputRate = isset($policy['input_usd_per_1m']) ? max(0.0, (float)$policy['input_usd_per_1m']) : self::DEFAULT_AI_INPUT_USD_PER_1M; - $outputRate = isset($policy['output_usd_per_1m']) ? max(0.0, (float)$policy['output_usd_per_1m']) : self::DEFAULT_AI_OUTPUT_USD_PER_1M; - $this->aiPolicy = [ - 'timeline' => $timeline, - 'batch_size' => $batchSize, - 'max_cost_usd' => $maxCost, - 'input_usd_per_1m' => $inputRate, - 'output_usd_per_1m' => $outputRate, - ]; - $this->resetAiUsage(); - return $this; - } - - public static function openAiFailureRequiresDurableRetry(openai_request_exception $exception, ?int $runId): bool - { - return $exception->retryable && $runId !== null && $runId > 0; - } - - public function evaluateUsageLogById(int $usageLogId, ?int $actorId = null, bool $allowExecute = true): array - { - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $row = $this->loadUsageLogRow($usageLogId); - if ($row === null) { - return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); - } - - return $this->evaluateUsageLogRow($row, $actorId, $allowExecute); - } - - public function evaluateUsageLogRow(array $row, ?int $actorId = null, bool $allowExecute = true): array - { - $usageLogId = (int)($row['id'] ?? 0); - if ($usageLogId < 1) { - return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.'); - } - if ((string)($row['import_state'] ?? '') === 'invalid') { - return [ - ...$this->emptyAutomation('XL Vask-kildedata kunne ikke valideres.'), - 'status' => self::STATUS_FAILED, - 'resolution_state' => 'failed', - ]; - } - - try { - $log = $this->usageLogFromRow($row); - $guard = $this->guardReason($log, false); - if ($guard !== null) { - return $this->emptyAutomation($guard); - } - $linkedOrder = $this->existingLinkedOrder($log); - if ($linkedOrder !== null) { - if ((string)($row['import_state'] ?? '') === 'updated' - || (string)($row['planned_action'] ?? '') === 'recheck') { - return [ - ...$this->emptyAutomation('Den tilknyttede ordre afventer revalidering mod den ændrede XL Vask-kilde.'), - 'resolution_state' => 'needs_review', - 'certainty' => 'uncertain', - 'planned_action' => 'recheck', - 'risk_flags' => ['linked_order_revision_mismatch'], - 'matched_order_id' => (int)($linkedOrder->id ?? 0), - ]; - } - $linkedContext = $this->buildContext($usageLogId, $log, $row); - $linkedItems = (new orders_o())->getOrderItems((int)($linkedOrder->id ?? 0)); - $linkedOrderData = $linkedOrder->asArray(true, false); - if (!self::linkedOrderMatchesForAutomation( - (array)$linkedContext['proposed_order'], - (array)$linkedContext['items'], - $linkedOrderData, - $linkedItems - )) { - return [ - ...$this->emptyAutomation('Den tilknyttede ordre matcher ikke den aktuelle XL Vask-revision.'), - 'resolution_state' => 'needs_review', - 'certainty' => 'uncertain', - 'planned_action' => 'recheck', - 'risk_flags' => ['linked_order_revision_mismatch'], - 'matched_order_id' => (int)($linkedOrder->id ?? 0), - ]; - } - return [ - ...$this->emptyAutomation('XL Vask-vasken er allerede knyttet til en ordre.'), - 'status' => self::STATUS_ACCEPTED, - 'matched_order_id' => (int)($linkedOrder->id ?? 0), - 'resolution_state' => 'already_linked', - 'certainty' => 'certain', - 'evidence' => [[ - 'type' => 'existing_link_semantically_revalidated', - 'value' => true, - ]], - ]; - } - - $existing = $this->latestTerminalSuggestion($usageLogId); - if ($existing !== null) { - if ((string)$existing['status'] === self::STATUS_SUGGESTED) { - if (!$allowExecute) { - return $this->formatSuggestion($existing); - } - - $context = $this->buildContext($usageLogId, $log, $row); - $contextGuard = $this->contextGuardReason($context); - if ($contextGuard !== null) { - return $this->emptyAutomation($contextGuard); - } - - $freshSuggestion = $this->buildSuggestionForContext($context); - if ($freshSuggestion !== null && !$this->readOnlyEvaluation) { - $freshSuggestion = $this->decorateSuggestionWithEvidence($freshSuggestion, $context); - $suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId); - $existing = $this->loadSuggestion($suggestionId) ?? $existing; - } - - if ($allowExecute && $this->shouldAutoExecute($existing, $context)) { - return $this->executeSuggestion($existing, $context, $actorId, true); - } - } - - return $this->formatSuggestion($existing); - } - - $context = $this->buildContext($usageLogId, $log, $row); - $contextGuard = $this->contextGuardReason($context); - if ($contextGuard !== null) { - return $this->emptyAutomation($contextGuard); - } - - if ($this->hasDeniedFeedback($context['signature_hash'], self::ACTION_ATTACH) - && $this->hasDeniedFeedback($context['signature_hash'], self::ACTION_CREATE)) { - return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); - } - - $suggestion = $this->buildSuggestionForContext($context); - - if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { - return $this->emptyAutomation('Ingen sikker automatiseringshandling fundet.'); - } - - if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { - return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); - } - - $suggestion = $this->decorateSuggestionWithEvidence($suggestion, $context); - if ($this->readOnlyEvaluation) { - return $this->formatTransientSuggestion($usageLogId, $suggestion); - } - $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); - $suggestionRow = $this->loadSuggestion($suggestionId); - if ($suggestionRow === null) { - return $this->emptyAutomation('Forslaget kunne ikke gemmes.'); - } - - if ($allowExecute && $this->shouldAutoExecute($suggestionRow, $context)) { - return $this->executeSuggestion($suggestionRow, $context, $actorId, true); - } - - return $this->formatSuggestion($suggestionRow); - } catch (openai_request_exception $e) { - if (self::openAiFailureRequiresDurableRetry($e, $this->runId)) { - throw $e; - } - return $this->emptyAutomation('OpenAI kunne ikke levere et anvendeligt forslag.'); - } catch (Exception $e) { - return [ - ...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'), - 'status' => self::STATUS_FAILED, - 'error' => $e->getMessage(), - ]; - } - } - - public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array - { - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $row = $this->loadUsageLogRow($usageLogId); - if ($row === null) { - return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); - } - - $log = $this->usageLogFromRow($row); - $guard = $this->guardReason($log); - if ($guard !== null) { - return $this->emptyAutomation($guard); - } - - $context = $this->buildContext($usageLogId, $log, $row); - $contextGuard = $this->contextGuardReason($context); - if ($contextGuard !== null) { - return $this->emptyAutomation($contextGuard); - } - - $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); - if ($suggestion === null) { - $this->evaluateUsageLogRow($row, $actorId, false); - $suggestion = $this->latestActionableSuggestion($usageLogId); - } - - if ($suggestion === null) { - return $this->emptyAutomation('Der er intet forslag at acceptere.'); - } - if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { - return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.'); - } - - $result = $this->executeSuggestion($suggestion, $context, $actorId, false); - $this->updateUsageLogAutomationState($usageLogId, $result, null); - if ((string)($result['status'] ?? '') === self::STATUS_ACCEPTED) { - $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($result['matched_order_id'] ?? $result['created_order_id'] ?? 0), $actorId, $reason); - } - - return $result; - } - - public function denyUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array - { - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $row = $this->loadUsageLogRow($usageLogId); - if ($row === null) { - return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); - } - - $log = $this->usageLogFromRow($row); - $context = $this->buildContext($usageLogId, $log, $row); - $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); - - if ($suggestion === null) { - return $this->emptyAutomation('Der er intet forslag at afvise.'); - } - if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { - return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.'); - } - - $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); - $this->persistFeedback($context, (string)$suggestion['action'], 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); - - $result = $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); - $this->updateUsageLogAutomationState($usageLogId, $result, null); - global $db; - $db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}"); - return $result; - } - - /** Apply a preview-bound decision while the caller owns the transaction and row locks. */ - public function applyBoundDecisionWithinTransaction( - int $usageLogId, - string $requestedAction, - ?int $suggestionId, - ?int $candidateOrderId, - int $expectedVersion, - string $sourceHash, - ?int $actorId, - ?string $reason = null - ): array { - global $db; - $rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE"); - $row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null; - if ($row === null - || (int)($row['expected_version'] ?? 0) !== $expectedVersion - || !hash_equals((string)($row['source_hash'] ?? ''), $sourceHash)) { - throw new Exception('XL Vask-vasken blev ændret efter forhåndsvisningen.'); - } - - $suggestionResult = $suggestionId === null ? false : $db->query( - "SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} FOR UPDATE" - ); - $suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0 - ? $db->fetch_assoc($suggestionResult) - : null; - if ($suggestion === null || (int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { - throw new Exception('Forslaget mangler eller tilhører en anden XL Vask-vask.'); - } - if ((string)($suggestion['status'] ?? '') !== self::STATUS_SUGGESTED) { - throw new Exception('Forslaget er ikke længere aktivt.'); - } - $suggestedAction = (string)$suggestion['action']; - if ($requestedAction === 'create_order' && $suggestedAction !== self::ACTION_CREATE) { - throw new Exception('Forslaget er ikke en ordreoprettelse.'); - } - - $context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row); - if ($requestedAction === self::ACTION_ATTACH) { - $candidate = $this->candidateFromContext($context, (int)$candidateOrderId); - if ($candidate === null) { - throw new Exception('Den valgte ordre er ikke længere en tilladt kandidat.'); - } - // Manual alternative selection is always uncertain and can never feed automatic certainty. - $suggestion['action'] = self::ACTION_ATTACH; - $suggestion['matched_order_id'] = (int)$candidate['id']; - $suggestion['candidate_order'] = $candidate; - $suggestion['candidate_order_json'] = json_encode($candidate, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $suggestion['certainty'] = 'uncertain'; - } - if ($requestedAction === 'deny') { - $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); - $this->persistFeedback($context, $suggestedAction, 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); - $this->syncUsageState($usageLogId, 'needs_review', 'none', 'none', $reason ?? 'Forslaget blev afvist.'); - if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false - || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask usage-log version could not be advanced atomically.'); - } - return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); - } - - $latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, false); - $executedAction = $requestedAction === 'accept' ? $suggestedAction : $requestedAction; - $this->persistFeedback( - $context, - $executedAction, - 'accepted', - (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), - $actorId, - $reason - ); - return $this->formatSuggestion($latest); - } - - /** Rebuild and validate a manual candidate against current server-side usage context. */ - public function validateManualCandidate(int $usageLogId, int $orderId): array - { - $row = $this->loadUsageLogRow($usageLogId); - if ($row === null || $orderId < 1) { - throw new Exception('The selected XL Vask order candidate was not found.'); - } - $context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row); - $candidate = $this->candidateFromContext($context, $orderId); - if ($candidate === null) { - throw new Exception('The selected order is not a current server-derived candidate.'); - } - return [ - 'id' => (int)$candidate['id'], - 'customer_id' => (int)($candidate['customer_id'] ?? 0), - 'department_id' => (int)($candidate['department_id'] ?? 0), - 'created_at' => $candidate['created_at'] ?? null, - ]; - } - - public function runPending( - ?string $dateFrom = null, - ?string $dateTo = null, - array $ids = [], - int $limit = 100, - ?int $actorId = null, - bool $allowExecute = true, - ?int $runId = null, - array $allowedHallIds = [], - bool $readOnlyEvaluation = false, - ?callable $heartbeat = null - ): array - { - xlvask_usage_logs_schema_bootstrap::ensureTables(); - if ($runId !== null) { - $this->setRunContext($runId); - } - if ($actorId !== null && $allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - $limit = max(1, min(500, $limit)); - $rows = $ids !== [] - ? array_slice($this->loadUsageLogRowsByIds($ids, $allowedHallIds), 0, $limit) - : $this->loadPendingRows($dateFrom, $dateTo, $limit, $allowedHallIds); - $eligibleTotal = $ids !== [] ? count($rows) : $this->countPendingRows($dateFrom, $dateTo, $allowedHallIds); - $results = []; - $autoLinks = 0; - $autoCreates = 0; - $attemptedActions = 0; - $actionFailures = 0; - $consecutiveFailures = 0; - $circuitBreaker = null; - $this->setReadOnlyEvaluation($readOnlyEvaluation); - $this->resetAiUsage(); - foreach ($rows as $row) { - if ($heartbeat !== null) { - $heartbeat(); - } - $executionAllowed = !$readOnlyEvaluation && $allowExecute && $autoLinks < 500 && $autoCreates < 100; - $result = $this->evaluateUsageLogRow($row, $actorId, $executionAllowed); - if (!$readOnlyEvaluation && (string)($row['resolution_state'] ?? '') !== 'ignored') { - $this->updateUsageLogAutomationState((int)($row['id'] ?? 0), $result, $runId); - } - $results[] = ['usage_log_id' => (int)($row['id'] ?? 0), ...$result]; - if ((bool)($result['control_stop'] ?? false)) { - $circuitBreaker = (string)($result['control_stop_reason'] ?? 'policy_control_stop'); - break; - } - if ((bool)($result['critical_invariant'] ?? false)) { - $circuitBreaker = 'critical_execution_invariant'; - break; - } - $status = (string)($result['status'] ?? self::STATUS_NONE); - $action = (string)($result['action'] ?? self::ACTION_NONE); - if ($status === self::STATUS_AUTO_ACCEPTED) { - $attemptedActions++; - $consecutiveFailures = 0; - if ($action === self::ACTION_CREATE) { - $autoCreates++; - } elseif ($action === self::ACTION_ATTACH) { - $autoLinks++; - } - } elseif ($status === self::STATUS_FAILED && $action !== self::ACTION_NONE) { - $attemptedActions++; - $actionFailures++; - $consecutiveFailures++; - $error = strtolower((string)($result['error'] ?? '')); - if (str_contains($error, 'allerede tilknyttet') - || str_contains($error, 'ændret efter evalueringen') - || str_contains($error, 'atomisk') - || str_contains($error, 'unik wash_id')) { - $circuitBreaker = 'critical_execution_invariant'; - } - } else { - $consecutiveFailures = 0; - } - - if ($circuitBreaker !== null - || $consecutiveFailures >= 3 - || ($attemptedActions >= 20 && ($actionFailures / $attemptedActions) > 0.02)) { - $circuitBreaker ??= $consecutiveFailures >= 3 ? 'three_consecutive_failures' : 'action_failure_rate'; - break; - } - } - - return [ - 'selected' => count($rows), - 'eligible_total' => $eligibleTotal, - 'processed' => count($results), - 'results' => $results, - 'automatic_links' => $autoLinks, - 'automatic_creations' => $autoCreates, - 'circuit_breaker' => $circuitBreaker, - 'ai_usage' => $this->aiUsage, - ]; - } - - /** Read-only automation projection for list/detail GET endpoints. */ - public function readAutomationStateByUsageLogId(int $usageLogId, array $usageRow = []): array - { - try { - $suggestion = $this->latestSuggestionWhere($usageLogId, [ - self::STATUS_SUGGESTED, - self::STATUS_AUTO_ACCEPTED, - self::STATUS_ACCEPTED, - self::STATUS_DENIED, - self::STATUS_FAILED, - ]); - $result = $suggestion === null ? $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')) : $this->formatSuggestion($suggestion); - if ($suggestion !== null) { - $result = [...$result, ...$this->readProjectionActionFlags($suggestion, $usageRow)]; - } - } catch (\Throwable) { - $result = $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')); - } - - return [ - ...$result, - 'usage_log_id' => $usageLogId, - 'import_state' => (string)($usageRow['import_state'] ?? 'unchanged'), - 'resolution_state' => (string)($usageRow['resolution_state'] ?? ($result['status'] === self::STATUS_FAILED ? 'failed' : 'needs_review')), - 'certainty' => (string)($usageRow['certainty'] ?? $result['certainty'] ?? 'none'), - 'planned_action' => (string)($usageRow['planned_action'] ?? $result['action'] ?? self::ACTION_NONE), - 'source_revision' => $usageRow['source_revision'] ?? null, - 'source_hash' => $usageRow['source_hash'] ?? null, - 'expected_version' => isset($usageRow['expected_version']) ? (int)$usageRow['expected_version'] : null, - 'run_id' => isset($usageRow['last_run_id']) ? (int)$usageRow['last_run_id'] : ($result['run_id'] ?? null), - ]; - } - - public static function classifyCertaintyForAutomation( - array $calibration, - bool $hardGuardsPass = true, - array $contradictions = [] - ): string { - if (!$hardGuardsPass || $contradictions !== []) { - return 'uncertain'; - } - - return (bool)($calibration['active'] ?? false) - && (float)($calibration['precision_value'] ?? 0) >= 0.995 - && (float)($calibration['wilson_lower_bound'] ?? 0) >= 0.98 - && (int)($calibration['overall_examples'] ?? 0) >= 200 - && (int)($calibration['segment_examples'] ?? 0) >= 30 - && (int)($calibration['holdout_examples'] ?? 0) > 0 - && (int)($calibration['contradictions'] ?? 0) === 0 - ? 'certain' - : 'uncertain'; - } - - public static function suggestionMatchesCurrentUsageForReview(array $suggestion, array $usageRow): bool - { - $suggestionVersion = isset($suggestion['expected_version']) ? (int)$suggestion['expected_version'] : 0; - $usageVersion = isset($usageRow['expected_version']) ? (int)$usageRow['expected_version'] : 0; - $suggestionHash = trim((string)($suggestion['input_hash'] ?? '')); - $usageHash = trim((string)($usageRow['source_hash'] ?? '')); - - return (string)($suggestion['status'] ?? '') === self::STATUS_SUGGESTED - && in_array((string)($suggestion['action'] ?? self::ACTION_NONE), [self::ACTION_ATTACH, self::ACTION_CREATE], true) - && (string)($usageRow['resolution_state'] ?? '') === 'needs_review' - && (string)($usageRow['import_state'] ?? '') !== 'invalid' - && empty($usageRow['ignored_at']) - && (!array_key_exists('FinishStatus', $usageRow) || (int)$usageRow['FinishStatus'] === 1) - && $suggestionVersion > 0 - && $suggestionVersion === $usageVersion - && $suggestionHash !== '' - && $usageHash !== '' - && hash_equals($suggestionHash, $usageHash); - } - - public static function suggestionMatchesLockedUsageForExecution(array $suggestion, array $lockedUsage): bool - { - return (int)($suggestion['usage_log_id'] ?? 0) === (int)($lockedUsage['id'] ?? 0) - && (int)($suggestion['expected_version'] ?? 0) > 0 - && (int)($suggestion['expected_version'] ?? 0) === (int)($lockedUsage['expected_version'] ?? 0) - && (string)($suggestion['input_hash'] ?? '') !== '' - && hash_equals((string)($suggestion['input_hash'] ?? ''), (string)($lockedUsage['source_hash'] ?? '')); - } - - public static function normalizeRegistrationForAutomation(string $registration): string - { - return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? ''); - } - - public static function sourceIsStableForAutomatic(array $source, ?int $now = null): bool - { - $stableSince = strtotime((string)($source['source_stable_since'] ?? '')); - return (int)($source['source_observation_count'] ?? 0) >= 2 - && !empty($source['source_observed_at']) - && $stableSince !== false - && $stableSince <= (($now ?? time()) - (int)(self::CREATE_MIN_AGE_HOURS * 3600)); - } - - public static function itemSignaturePartsForAutomation(array $items): array - { - $parts = []; - foreach ($items as $item) { - if (!is_array($item)) { - continue; - } - - $parts[] = implode(':', [ - (int)($item['product_id'] ?? 0), - (int)($item['quantity'] ?? 0), - (int)($item['price'] ?? 0), - ]); - } - - sort($parts, SORT_STRING); - return $parts; - } - - public static function scoreItemMatchForAutomation(array $usageItems, array $orderItems): array - { - $usageSignature = self::itemSignaturePartsForAutomation($usageItems); - $orderSignature = self::itemSignaturePartsForAutomation($orderItems); - $usageTotal = self::itemsTotalForAutomation($usageItems); - $orderTotal = self::itemsTotalForAutomation($orderItems); - - if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { - return [ - 'confidence' => 0.95, - 'source' => self::SOURCE_DETERMINISTIC, - 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', - ]; - } - - $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); - $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); - if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { - return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; - } - - $overlap = self::productOverlapForAutomation($usageItems, $orderItems); - $totalDiff = abs($usageTotal - $orderTotal); - if ($overlap >= 0.70 && $totalDiff <= 50) { - return [ - 'confidence' => 0.93, - 'source' => self::SOURCE_FUZZY, - 'reason' => 'Samme primære produkt og relaterede tilføjelser matcher en ordre fra samme dag.', - ]; - } - - if ($overlap >= 0.50 && $totalDiff <= 150) { - return [ - 'confidence' => 0.80, - 'source' => self::SOURCE_FUZZY, - 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', - ]; - } - - $matchableUsageItems = self::matchableUsageItemsForAutomation($usageItems); - $matchableOverlap = self::productOverlapForAutomation($matchableUsageItems, $orderItems); - if ( - $matchableUsageItems !== [] - && $matchableOverlap >= 0.95 - && self::orderHasAdditionsBeyondUsage($matchableUsageItems, $orderItems) - ) { - return [ - 'confidence' => 0.88, - 'source' => self::SOURCE_FUZZY, - 'reason' => 'Ordren indeholder XL Vask-produkterne samt ekstra ydelser fra samme dag.', - ]; - } - - return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; - } - - public static function itemsTotalForAutomation(array $items): int - { - return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); - } - - public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool - { - return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems) - && self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems); - } - - public static function linkedOrderMatchesForAutomation( - array $proposedOrder, - array $proposedItems, - array $linkedOrder, - array $linkedItems - ): bool { - if (!self::isExactItemMatchForAutomation($proposedItems, $linkedItems) - || (int)($proposedOrder['customer_id'] ?? 0) < 1 - || (int)($proposedOrder['customer_id'] ?? 0) !== (int)($linkedOrder['customer_id'] ?? 0) - || (int)($proposedOrder['department_id'] ?? 0) < 1 - || (int)($proposedOrder['department_id'] ?? 0) !== (int)($linkedOrder['department_id'] ?? 0)) { - return false; - } - $proposedRegistrations = array_values(array_unique(array_filter(array_map( - [self::class, 'normalizeRegistrationForAutomation'], - [(string)($proposedOrder['reg_1'] ?? ''), (string)($proposedOrder['reg_2'] ?? ''), (string)($proposedOrder['reg_3'] ?? '')] - )))); - $linkedRegistrations = array_values(array_unique(array_filter(array_map( - [self::class, 'normalizeRegistrationForAutomation'], - [(string)($linkedOrder['reg_1'] ?? ''), (string)($linkedOrder['reg_2'] ?? ''), (string)($linkedOrder['reg_3'] ?? '')] - )))); - if ($proposedRegistrations === [] || array_diff($proposedRegistrations, $linkedRegistrations) !== []) { - return false; - } - $proposedLane = (int)($proposedOrder['lane'] ?? 0); - if ($proposedLane > 0 && $proposedLane !== (int)($linkedOrder['lane'] ?? 0)) { - return false; - } - $proposedDate = substr((string)($proposedOrder['created_at'] ?? ''), 0, 10); - $linkedDate = substr((string)($linkedOrder['created_at'] ?? ''), 0, 10); - return $proposedDate === '' || $linkedDate === '' || $proposedDate === $linkedDate; - } - - public static function productOverlapForAutomation(array $usageItems, array $orderItems): float - { - $usageBag = self::productBagForAutomation($usageItems); - $orderBag = self::productBagForAutomation($orderItems); - $usageTotal = array_sum($usageBag); - if ($usageTotal <= 0) { - return 0.0; - } - - $overlap = 0; - foreach ($usageBag as $productId => $quantity) { - $overlap += min($quantity, $orderBag[$productId] ?? 0); - } - - return $overlap / $usageTotal; - } - - public static function productBagForAutomation(array $items): array - { - $bag = []; - foreach ($items as $item) { - $productId = (int)($item['product_id'] ?? 0); - if ($productId < 1) { - continue; - } - $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); - } - - return $bag; - } - - private static function matchableUsageItemsForAutomation(array $items): array - { - $positiveItems = array_values(array_filter($items, static function (array $item): bool { - return (int)($item['product_id'] ?? 0) > 0 - && (int)($item['quantity'] ?? 0) > 0 - && (int)($item['price'] ?? 0) > 0; - })); - - if ($positiveItems !== []) { - return $positiveItems; - } - - return array_values(array_filter($items, static function (array $item): bool { - return (int)($item['product_id'] ?? 0) > 0 - && (int)($item['quantity'] ?? 0) > 0; - })); - } - - private static function orderHasAdditionsBeyondUsage(array $usageItems, array $orderItems): bool - { - $usageBag = self::productBagForAutomation($usageItems); - foreach (self::productBagForAutomation($orderItems) as $productId => $quantity) { - if ($quantity > ($usageBag[$productId] ?? 0)) { - return true; - } - } - - return false; - } - - public static function normalizeUsageLogRowForAutomation(array $row): array - { - unset($row['id']); - - $washItems = $row['WashItems'] ?? []; - if (is_string($washItems)) { - $decoded = json_decode($washItems, true); - $row['WashItems'] = is_array($decoded) ? $decoded : []; - } elseif (!is_array($washItems)) { - $row['WashItems'] = []; - } - - return $row; - } - - public static function openAiCacheKeyForAutomation( - string $schemaName, - string $prompt, - array $payload, - array $schema, - float $temperature - ): string { - $input = [ - 'version' => self::OPENAI_CACHE_VERSION, - 'planner_identity' => self::automationIdentityForAutomation(), - 'schema_name' => $schemaName, - 'prompt' => $prompt, - 'payload' => $payload, - 'schema' => $schema, - 'temperature' => round($temperature, 4), - ]; - - return hash('sha256', self::stableJsonForAutomation($input)); - } - - public static function automationIdentityForAutomation(): array - { - $identity = [ - 'policy_version' => self::POLICY_VERSION, - 'model' => self::PLANNER_MODEL, - 'prompt_version' => self::PLANNER_PROMPT_VERSION, - 'prompt_hash' => hash('sha256', self::PLANNER_PROMPT), - 'schema_version' => self::PLANNER_SCHEMA_VERSION, - 'schema_hash' => hash('sha256', self::stableJsonForAutomation(self::openAiPlannerSchemaForAutomation())), - 'temperature' => 0.1, - 'cache_version' => self::OPENAI_CACHE_VERSION, - ]; - return [...$identity, 'identity_hash' => hash('sha256', self::stableJsonForAutomation($identity))]; - } - - public static function openAiPlannerSchemaForAutomation(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'action' => ['type' => 'string', 'enum' => [self::ACTION_ATTACH, self::ACTION_CREATE, self::ACTION_NONE]], - 'confidence' => ['type' => 'number'], - 'reason_da' => ['type' => 'string'], - 'candidate_order_id' => ['type' => ['integer', 'null']], - 'proposed_order_items' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'product_id' => ['type' => 'integer'], - 'quantity' => ['type' => 'integer'], - 'price' => ['type' => 'integer'], - ], - 'required' => ['product_id', 'quantity', 'price'], - 'additionalProperties' => false, - ], - ], - 'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']], - 'evidence' => ['type' => 'array', 'items' => ['type' => 'string']], - 'contradictions' => ['type' => 'array', 'items' => ['type' => 'string']], - 'plan_steps' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'step' => ['type' => 'string'], - 'reason' => ['type' => 'string'], - ], - 'required' => ['step', 'reason'], - 'additionalProperties' => false, - ], - ], - ], - 'required' => [ - 'action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items', - 'risk_flags', 'evidence', 'contradictions', 'plan_steps', - ], - 'additionalProperties' => false, - ]; - } - - public static function stableJsonForAutomation(mixed $value): string - { - $encoded = json_encode( - self::normalizeForStableJson($value), - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION - ); - - if ($encoded === false) { - throw new Exception('Kunne ikke opbygge en stabil cache-nøgle for XL Vask-automatisering.'); - } - - return $encoded; - } - - private static function normalizeForStableJson(mixed $value): mixed - { - if (!is_array($value)) { - return $value; - } - - $normalized = array_map(fn(mixed $item): mixed => self::normalizeForStableJson($item), $value); - $isList = $normalized === [] || array_keys($normalized) === range(0, count($normalized) - 1); - if (!$isList) { - ksort($normalized, SORT_STRING); - } - - return $normalized; - } - - private function buildDeterministicSuggestion(array $context): ?array - { - $best = null; - foreach ($context['candidate_orders'] as $candidate) { - $score = $this->scoreOrderMatch($context['items'], $candidate['order_items']); - if ($score['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { - continue; - } - - $candidateSuggestion = [ - 'action' => self::ACTION_ATTACH, - 'confidence' => $score['confidence'], - 'source' => $score['source'], - 'matched_order_id' => (int)$candidate['id'], - 'created_order_id' => null, - 'candidate_order' => $candidate, - 'proposed_order' => $context['proposed_order'], - 'reason' => $score['reason'] . ' Ordre #' . (int)$candidate['id'] . '.', - ]; - - if ($best === null || $candidateSuggestion['confidence'] > $best['confidence']) { - $best = $candidateSuggestion; - } - } - - if ($best !== null && $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_ATTACH)) { - $best['confidence'] = max($best['confidence'], 0.96); - $best['source'] = self::SOURCE_HISTORY; - $best['reason'] = 'Tidligere godkendt mønster for køretøjet matcher ordre #' . (int)$best['matched_order_id'] . '.'; - } - - if ($best !== null) { - return $best; - } - - if ($context['age_hours'] >= self::CREATE_MIN_AGE_HOURS) { - $history = $this->findMatchingHistoricalOrder($context); - if ($history !== null || $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_CREATE)) { - return [ - 'action' => self::ACTION_CREATE, - 'confidence' => 0.98, - 'source' => self::SOURCE_HISTORY, - 'matched_order_id' => null, - 'created_order_id' => null, - 'candidate_order' => $history, - 'proposed_order' => $context['proposed_order'], - 'reason' => 'Vasken er over 6 timer gammel og matcher et tidligere godkendt køretøjsmønster.', - ]; - } - } - - return null; - } - - private function buildSuggestionForContext(array $context): ?array - { - $suggestion = $this->buildDeterministicSuggestion($context); - - if ( - ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) - && $this->isOpenAiEnabled() - ) { - $suggestion = $this->buildOpenAiSuggestion($context) ?? $suggestion; - } - - if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { - return null; - } - - if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { - return null; - } - - return $suggestion; - } - - private function buildOpenAiSuggestion(array $context): ?array - { - try { - $schemaName = 'xlvask_automation'; - $prompt = self::PLANNER_PROMPT; - $temperature = 0.1; - $schema = self::openAiPlannerSchemaForAutomation(); - - $creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS; - $payload = [ - 'usage_log' => [ - 'lane' => $context['signature']['lane'], - 'created_at' => $context['proposed_order']['created_at'] ?? null, - 'total_net_amount' => $context['total'], - 'items' => $this->compactItems($context['items']), - 'creation_allowed' => $creationAllowed, - 'age_bucket' => $creationAllowed ? 'older_than_6_hours' : 'newer_than_6_hours', - ], - 'candidate_orders' => array_map(fn(array $candidate): array => [ - 'id' => (int)$candidate['id'], - 'created_at' => $candidate['created_at'] ?? null, - 'total_net_amount' => (int)($candidate['total_net_amount'] ?? 0), - 'items' => $this->compactItems($candidate['order_items'] ?? []), - ], $context['candidate_orders']), - ]; - - $cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature); - $result = $this->loadOpenAiCacheResult($cacheKey); - $fromCache = $result !== null; - if ($fromCache) { - $this->aiUsage['cache_hits']++; - } - if ($result === null) { - if ($this->readOnlyEvaluation) { - return null; - } - if ($this->openAiBudgetReached()) { - return null; - } - $openai = new minimax(); - $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL); - $result = $this->sanitizeOpenAiResult($result, $context); - $this->recordOpenAiUsage((array)($result['usage'] ?? [])); - if (!$this->readOnlyEvaluation) { - $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); - } - } - $result = $this->sanitizeOpenAiResult($result, $context); - - $action = (string)($result['action'] ?? self::ACTION_NONE); - $confidence = (float)($result['confidence'] ?? 0); - $resolvedModel = (string)($result['resolved_model'] ?? ''); - if (!hash_equals(self::PLANNER_MODEL, $resolvedModel)) { - return null; - } - if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) { - return null; - } - - if ($action === self::ACTION_CREATE && $context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { - return null; - } - - $candidate = null; - $candidateOrderId = (int)($result['candidate_order_id'] ?? 0); - if ($action === self::ACTION_ATTACH) { - foreach ($context['candidate_orders'] as $candidateOrder) { - if ((int)$candidateOrder['id'] === $candidateOrderId) { - $candidate = $candidateOrder; - break; - } - } - if ($candidate === null) { - return null; - } - } - - return [ - 'action' => $action, - 'confidence' => min(1.0, max(0.0, $confidence)), - 'source' => self::SOURCE_OPENAI, - 'resolved_model' => $resolvedModel, - 'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null, - 'created_order_id' => null, - 'candidate_order' => $candidate, - 'proposed_order' => $context['proposed_order'], - 'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'), - 'risk_flags' => array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))), - 'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))), - 'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')), - 'usage' => (array)($result['usage'] ?? []), - ]; - } catch (openai_request_exception $exception) { - // Retryable provider/transport failures must reach the durable run - // scheduler. Refusal and other non-retryable outcomes become no-action. - throw $exception; - } catch (Exception) { - return null; - } - } - - private function sanitizeOpenAiResult(array $result, array $context): array - { - $redactions = array_values(array_filter([ - (string)($context['wash_id'] ?? ''), - (string)($context['signature']['registration'] ?? ''), - (string)($context['signature']['customer_number'] ?? ''), - ], static fn(string $value): bool => strlen($value) >= 3)); - $sanitize = static function (mixed $value, int $maxLength = 256) use ($redactions): string { - $value = mb_substr((string)$value, 0, $maxLength); - foreach ($redactions as $redaction) { - $value = str_ireplace($redaction, '[redacted]', $value); - } - return preg_replace('/[^\pL\pN _.,:;!?()\[\]#\-]/u', '', $value) ?? ''; - }; - $stringList = static fn(mixed $value): array => array_slice(array_values(array_filter(array_map( - static fn(mixed $item): string => $sanitize($item), - (array)$value - ), static fn(string $item): bool => $item !== '')), 0, 20); - $planSteps = array_slice(array_values(array_filter(array_map( - static fn(mixed $step): ?array => is_array($step) ? [ - 'step' => $sanitize($step['step'] ?? '', 128), - 'reason' => $sanitize($step['reason'] ?? '', 256), - ] : null, - (array)($result['plan_steps'] ?? []) - ))), 0, 20); - - return [ - 'action' => (string)($result['action'] ?? self::ACTION_NONE), - 'confidence' => min(1.0, max(0.0, (float)($result['confidence'] ?? 0))), - 'resolved_model' => preg_replace('/[^a-zA-Z0-9_.:-]/', '', (string)($result['_openai_response_model'] ?? $result['resolved_model'] ?? '')) ?? '', - 'reason_da' => $sanitize($result['reason_da'] ?? '', 1000), - 'candidate_order_id' => isset($result['candidate_order_id']) ? (int)$result['candidate_order_id'] : null, - 'proposed_order_items' => array_slice(array_values(array_filter((array)($result['proposed_order_items'] ?? []), 'is_array')), 0, 50), - 'risk_flags' => $stringList($result['risk_flags'] ?? []), - 'evidence' => $stringList($result['evidence'] ?? []), - 'contradictions' => $stringList($result['contradictions'] ?? []), - 'plan_steps' => $planSteps, - 'usage' => [ - 'input_tokens' => max(0, (int)($result['_openai_usage']['input_tokens'] ?? 0)), - 'output_tokens' => max(0, (int)($result['_openai_usage']['output_tokens'] ?? 0)), - 'total_tokens' => max(0, (int)($result['_openai_usage']['total_tokens'] ?? 0)), - 'service_tier' => (string)($result['_openai_usage']['service_tier'] ?? ''), - ], - ]; - } - - private function shouldAutoExecute(array $suggestion, array $context): bool - { - $confidence = (float)$suggestion['confidence']; - $action = (string)$suggestion['action']; - $xlvask = new xlvask(); - - if ((string)($suggestion['source'] ?? '') !== self::SOURCE_OPENAI) { - return false; - } - - if ((string)($suggestion['certainty'] ?? '') !== 'certain' - || !$this->hardGuardsPassForCertainty($suggestion, $context) - || !(new xlvask_automation_policy_service())->policyAllowsActionReadOnly($action)) { - return false; - } - - if ($action === self::ACTION_ATTACH) { - return $xlvask->config->automatic_order_attachment_enabled->isTrue() - && $confidence >= self::AUTO_ATTACH_CONFIDENCE - && ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI - ? $this->openAiAttachHardGuardsPass($suggestion, $context) - : $this->isExactAttachSuggestionForContext($suggestion, $context)); - } - - if ($action === self::ACTION_CREATE) { - return $xlvask->config->automatic_order_creation_enabled->isTrue() - && $confidence >= self::AUTO_CREATE_CONFIDENCE - && $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS - && $context['candidate_orders'] === []; - } - - return false; - } - - private function hardGuardsPassForCertainty(array $suggestion, array $context): bool - { - if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady() - || !self::sourceIsStableForAutomatic($context)) { - return false; - } - - $action = (string)($suggestion['action'] ?? ''); - if ($action === self::ACTION_ATTACH) { - if ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI) { - return $this->openAiAttachHardGuardsPass($suggestion, $context); - } - return $this->isExactAttachSuggestionForContext($suggestion, $context); - } - if ($action === self::ACTION_CREATE) { - return (float)($context['age_hours'] ?? 0) >= self::CREATE_MIN_AGE_HOURS - && (array)($context['candidate_orders'] ?? []) === [] - && (array)($context['items'] ?? []) !== []; - } - - return false; - } - - private function openAiAttachHardGuardsPass(array $suggestion, array $context): bool - { - $candidate = $this->candidateOrderFromSuggestion($suggestion); - if (!is_array($candidate) - || count((array)($context['candidate_orders'] ?? [])) !== 1 - || (int)($candidate['id'] ?? 0) !== (int)($suggestion['matched_order_id'] ?? 0) - || (int)($candidate['customer_id'] ?? 0) !== (int)($context['proposed_order']['customer_id'] ?? 0) - || (int)($candidate['department_id'] ?? 0) !== (int)($context['proposed_order']['department_id'] ?? 0) - || (int)($candidate['lane'] ?? 0) !== (int)($context['proposed_order']['lane'] ?? 0) - || (int)($candidate['invoice_collection_id'] ?? 0) !== 0 - || (int)($candidate['booking_id'] ?? 0) !== 0 - || trim((string)($candidate['wash_id'] ?? '')) !== '') { - return false; - } - $expectedRegistration = self::normalizeRegistrationForAutomation((string)($context['proposed_order']['reg_1'] ?? '')); - $candidateRegistrations = array_map( - static fn(string $registration): string => self::normalizeRegistrationForAutomation($registration), - [(string)($candidate['reg_1'] ?? ''), (string)($candidate['reg_2'] ?? ''), (string)($candidate['reg_3'] ?? '')] - ); - $sourceTime = strtotime((string)($context['proposed_order']['created_at'] ?? '')); - $candidateTime = strtotime((string)($candidate['created_at'] ?? '')); - return $expectedRegistration !== '' && in_array($expectedRegistration, $candidateRegistrations, true) - && self::isExactItemMatchForAutomation((array)($context['items'] ?? []), (array)($candidate['order_items'] ?? [])) - && $sourceTime !== false && $candidateTime !== false - && abs($sourceTime - $candidateTime) <= self::OPENAI_ATTACH_MAX_DISTANCE_HOURS * 3600; - } - - private function decorateSuggestionWithEvidence(array $suggestion, array $context): array - { - $evidence = [[ - 'type' => 'source_revision', - 'value' => (string)($context['source_revision'] ?? ''), - ]]; - if ($this->isExactAttachSuggestionForContext($suggestion, $context)) { - $evidence[] = ['type' => 'exact_item_and_total_match', 'value' => true]; - } - if ((string)$suggestion['source'] === self::SOURCE_HISTORY) { - $evidence[] = ['type' => 'audited_history_pattern', 'value' => true]; - } - - $contradictions = []; - if ((string)$suggestion['action'] === self::ACTION_ATTACH) { - $sameConfidenceCandidates = array_filter( - $context['candidate_orders'], - fn(array $candidate): bool => $this->scoreOrderMatch($context['items'], $candidate['order_items'])['confidence'] - >= (float)$suggestion['confidence'] - ); - if (count($sameConfidenceCandidates) > 1) { - $contradictions[] = 'multiple_equally_credible_orders'; - } - } - if ((string)$suggestion['source'] === self::SOURCE_OPENAI) { - $contradictions = array_values(array_unique([ - ...$contradictions, - ...(array)($suggestion['contradictions'] ?? []), - ])); - } - - $segment = (string)$suggestion['source'] . ':' . (string)$suggestion['action']; - $calibration = $this->loadCalibration($segment); - $certainty = self::classifyCertaintyForAutomation( - $calibration ?? [], - $this->hardGuardsPassForCertainty($suggestion, $context), - $contradictions - ); - $riskFlags = (array)($suggestion['risk_flags'] ?? []); - if ($calibration === null) { - $riskFlags[] = 'calibration_artifact_unavailable'; - } - if ((string)$suggestion['source'] === self::SOURCE_OPENAI && $certainty !== 'certain') { - $riskFlags[] = 'ai_not_calibrated_for_automatic_action'; - } - - return [ - ...$suggestion, - 'certainty' => $certainty, - 'model' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? ($suggestion['resolved_model'] ?? null) : null, - 'planner_identity_hash' => (string)self::automationIdentityForAutomation()['identity_hash'], - 'model_confidence' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? (float)$suggestion['confidence'] : null, - 'calibrated_probability' => $calibration === null ? null : (float)$calibration['calibrated_probability'], - 'evidence' => $evidence, - 'contradictions' => $contradictions, - 'risk_flags' => array_values(array_unique($riskFlags)), - 'plan_steps' => (array)($suggestion['plan_steps'] ?? [ - ['step' => 'revalidate_source', 'status' => 'planned'], - ['step' => (string)$suggestion['action'], 'status' => 'planned'], - ['step' => 'verify_result', 'status' => 'planned'], - ]), - ]; - } - - private function loadCalibration(string $segmentKey): ?array - { - global $db; - $segmentKey = $db->escape_string($segmentKey); - $policy = $db->escape_string(self::POLICY_VERSION); - $result = $db->query( - "SELECT * FROM xlvask_automation_calibrations - WHERE policy_version = '{$policy}' AND segment_key = '{$segmentKey}' - AND active = 1 AND invalidated_at IS NULL - ORDER BY id DESC LIMIT 1" - ); - if ($result === false || $result->num_rows < 1) { - return null; - } - $row = $db->fetch_assoc($result); - $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); - if (!is_array($artifact) - || ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' - || ($artifact['policy_version'] ?? null) !== self::POLICY_VERSION - || ($artifact['segment_key'] ?? null) !== $segmentKey - || !hash_equals( - (string)($artifact['automation_identity_hash'] ?? ''), - (string)self::automationIdentityForAutomation()['identity_hash'] - ) - || !hash_equals( - (string)($row['artifact_hash'] ?? ''), - hash('sha256', self::stableJsonForAutomation($artifact)) - )) { - return null; - } - return [ - ...$artifact, - 'id' => (int)$row['id'], - 'active' => (bool)$row['active'], - 'artifact_hash' => (string)$row['artifact_hash'], - ]; - } - - private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool - { - if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) { - return false; - } - - $matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0); - $candidateOrder = $this->candidateOrderFromSuggestion($suggestion); - if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) { - return false; - } - - $usageItems = $context['items'] ?? []; - $orderItems = $candidateOrder['order_items'] ?? []; - return is_array($usageItems) - && is_array($orderItems) - && self::isExactItemMatchForAutomation($usageItems, $orderItems); - } - - private function candidateOrderFromSuggestion(array $suggestion): ?array - { - $candidateOrder = $suggestion['candidate_order'] ?? null; - if (is_array($candidateOrder)) { - return $candidateOrder; - } - - $candidateOrderJson = $suggestion['candidate_order_json'] ?? null; - if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') { - return null; - } - - $decoded = json_decode($candidateOrderJson, true); - return is_array($decoded) ? $decoded : null; - } - - private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array - { - global $db; - $connection = $db->conn(); - try { - $connection->begin_transaction(); - $latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, $automatic); - if ($automatic) { - $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context)); - } - $connection->commit(); - return $this->formatSuggestion($latest); - } catch (\Throwable $e) { - try { - $connection->rollback(); - } catch (\Throwable) { - } - if ($e instanceof xlvask_automation_control_stop) { - return [ - ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), - 'control_stop' => true, - 'control_stop_reason' => $e->reasonCode, - 'warning' => 'Automatic XL Vask actions are paused by server policy.', - ]; - } - $this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId); - $criticalInvariant = $automatic && $this->isCriticalAutomaticInvariantFailure($e->getMessage()); - if ($criticalInvariant) { - (new xlvask_automation_policy_service())->haltActionForCriticalInvariant( - (string)($suggestion['action'] ?? self::ACTION_NONE), - $e->getMessage() - ); - } - return [ - ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), - 'status' => self::STATUS_FAILED, - 'error' => 'The automatic XL Vask action failed closed.', - 'critical_invariant' => $criticalInvariant, - ]; - } - } - - /** - * Create a manual operator suggestion so the standard decision preview/apply - * pipeline can run without requiring an AI autopilot suggestion. The proposed - * order and candidate scan are deterministic (driven by simulateOrderFromXLVask + - * findSameDayCandidateOrders) so we always have a proposal to act on. - * - * @return int Suggestion id (newly inserted or updated) - */ - public function createManualSuggestion(int $usageLogId, string $action, ?int $actorId, array $allowedHallIds = []): int - { - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - $action = strtolower(trim($action)); - if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE, 'ignore'], true)) { - throw new Exception('Invalid manual XL Vask action.'); - } - - global $db; - $rowResult = $db->query( - 'SELECT * FROM xlvask_usage_logs WHERE id = ' . (int)$usageLogId - . ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') LIMIT 1' - ); - $row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null; - if ($row === null) { - throw new Exception('XL Vask usage log not found or not in scope.'); - } - if ((string)($row['import_state'] ?? '') === 'invalid') { - throw new Exception('XL Vask usage log has invalid source data.'); - } - if (in_array((string)($row['resolution_state'] ?? ''), ['auto_created', 'auto_linked', 'reviewed'], true)) { - throw new Exception('XL Vask usage log is already resolved.'); - } - - $log = $this->usageLogFromRow($row); - $context = $this->buildContext($usageLogId, $log, $row); - $suggestion = [ - 'action' => $action, - 'confidence' => 0.0, - 'source' => self::SOURCE_MANUAL, - 'certainty' => 'manual', - 'evidence' => [['kind' => 'manual_review', 'detail' => 'Operator triggered review action without an AI suggestion.']], - 'contradictions' => [], - 'risk_flags' => [], - 'plan_steps' => [], - 'matched_order_id' => $action === self::ACTION_ATTACH - ? ($this->pickFirstCandidate($context)['id'] ?? null) - : null, - 'proposed_order' => $context['proposed_order'] ?? [], - 'candidate_order' => $action === self::ACTION_ATTACH - ? ($this->pickFirstCandidate($context) ?: null) - : null, - 'reason' => 'Manual operator review.', - ]; - - $this->runId = null; - $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); - $this->runId = null; - return $suggestionId; - } - - private function pickFirstCandidate(array $context): ?array - { - $candidates = is_array($context['candidate_orders'] ?? null) ? $context['candidate_orders'] : []; - foreach ($candidates as $candidate) { - if (is_array($candidate) && isset($candidate['id'])) { - return $candidate; - } - } - return null; - } - - private function executeSuggestionWithinTransaction(array $suggestion, array $context, ?int $actorId, bool $automatic): array - { - global $db; - if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) { - throw new Exception('Aktivering er blokeret, indtil unik wash_id-migrering er verificeret.'); - } - - $usageLogId = (int)$context['usage_log_id']; - $usageResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE"); - $lockedUsage = $usageResult !== false && $usageResult->num_rows > 0 ? $db->fetch_assoc($usageResult) : null; - if ($lockedUsage === null) { - throw new Exception('XL Vask-vasken findes ikke længere.'); - } - if ((int)($lockedUsage['expected_version'] ?? 0) !== (int)($context['expected_version'] ?? 0) - || !hash_equals((string)($lockedUsage['source_hash'] ?? ''), (string)($context['source_hash'] ?? ''))) { - throw new Exception('XL Vask-kildedata blev ændret efter evalueringen.'); - } - if (!self::suggestionMatchesLockedUsageForExecution($suggestion, $lockedUsage)) { - throw new Exception('XL Vask-forslaget matcher ikke længere den låste kilderevision.'); - } - if (!empty($lockedUsage['ignored_at']) || (int)($lockedUsage['FinishStatus'] ?? 0) !== 1) { - throw new Exception('XL Vask-vasken er ikke længere behandlingsklar.'); - } - if ($automatic && !self::sourceIsStableForAutomatic($lockedUsage)) { - throw new Exception('XL Vask-kildedata mangler to observationer eller stabilitetsvinduet.'); - } - $washId = $db->escape_string((string)$context['wash_id']); - $duplicateResult = $db->query( - "SELECT id FROM orders - WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{$washId}')), '') FOR UPDATE" - ); - if ($duplicateResult !== false && $duplicateResult->num_rows > 0) { - throw new Exception('Vasken er allerede tilknyttet en ordre.'); - } - - $action = (string)$suggestion['action']; - $lockedLog = $this->usageLogFromRow($lockedUsage); - $currentCandidates = $this->findSameDayCandidateOrders($lockedLog, (array)$context['proposed_order'], true); - $currentContext = [...$context, 'candidate_orders' => $currentCandidates]; - if ($automatic) { - if ($action === self::ACTION_ATTACH - && (count($currentCandidates) !== 1 - || (int)($currentCandidates[0]['id'] ?? 0) !== (int)($suggestion['matched_order_id'] ?? 0))) { - throw new Exception('Den aktuelle serverafledte ordrekandidat er ikke længere entydig.'); - } - if ($action === self::ACTION_CREATE && $currentCandidates !== []) { - throw new Exception('En ny matchende ordre blev fundet før ordreoprettelsen.'); - } - (new xlvask_automation_policy_service())->reserveAutomaticAction($suggestion, $currentContext); - } - $matchedOrderId = null; - $createdOrderId = null; - if ($action === self::ACTION_ATTACH) { - $orderId = (int)$suggestion['matched_order_id']; - $allowedCandidateIds = array_map(static fn(array $candidate): int => (int)$candidate['id'], $currentCandidates); - if ($orderId < 1 || !in_array($orderId, $allowedCandidateIds, true)) { - throw new Exception('Ordren er ikke længere en tilladt kandidat.'); - } - $orderResult = $db->query("SELECT * FROM orders WHERE id = {$orderId} FOR UPDATE"); - $orderRow = $orderResult !== false && $orderResult->num_rows > 0 ? $db->fetch_assoc($orderResult) : null; - if ($orderRow === null - || !empty($orderRow['deleted_at']) - || trim((string)($orderRow['wash_id'] ?? '')) !== '' - || (int)($orderRow['invoice_collection_id'] ?? 0) > 0 - || (int)($orderRow['booking_id'] ?? 0) > 0 - || (int)$orderRow['customer_id'] !== (int)$context['proposed_order']['customer_id'] - || (int)$orderRow['department_id'] !== (int)$context['proposed_order']['department_id']) { - throw new Exception('Ordren er ændret eller økonomisk låst.'); - } - $orderItems = (new orders_o())->getOrderItems($orderId); - if ($automatic) { - $serverGuardsPass = (string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI - ? $this->openAiAttachHardGuardsPass([ - ...$suggestion, - 'candidate_order' => [...$orderRow, 'order_items' => $orderItems], - ], $currentContext) - : self::isExactItemMatchForAutomation($context['items'], $orderItems); - if (!$serverGuardsPass) { - throw new Exception('Ordrelinjer, tid eller beløb matcher ikke længere den kalibrerede regel.'); - } - } - if ($db->query("UPDATE orders SET wash_id = '{$washId}' WHERE id = {$orderId}") === false - || $db->conn()->affected_rows !== 1) { - throw new Exception('Ordretilknytningen kunne ikke gemmes entydigt.'); - } - $matchedOrderId = $orderId; - } elseif ($action === self::ACTION_CREATE) { - if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS - || empty($lockedUsage['source_stable_since']) - || strtotime((string)$lockedUsage['source_stable_since']) > strtotime('-' . self::CREATE_MIN_AGE_HOURS . ' hours') - || empty($lockedUsage['source_observed_at'])) { - throw new Exception('Vasken har ikke været stabil gennem observationsvinduet.'); - } - if ($currentCandidates !== [] || $this->itemsTotal($context['items']) !== (int)$context['total']) { - throw new Exception('Ordreoprettelsen kan ikke afstemmes sikkert.'); - } - $order = $this->createOrderFromContext($context); - $createdOrderId = (int)$order->id; - } else { - throw new Exception('Ukendt automatiseringshandling.'); - } - - $status = $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED; - $this->updateSuggestionExecution( - (int)$suggestion['id'], - $status, - $actorId, - $matchedOrderId, - $createdOrderId, - $action, - (string)($suggestion['certainty'] ?? 'uncertain'), - isset($suggestion['candidate_order_json']) - ? (string)$suggestion['candidate_order_json'] - : json_encode($suggestion['candidate_order'] ?? null, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) - ); - $resolution = $action === self::ACTION_CREATE - ? ($automatic ? 'auto_created' : 'already_linked') - : ($automatic ? 'auto_linked' : 'already_linked'); - $this->syncUsageState($usageLogId, $resolution, (string)($suggestion['certainty'] ?? 'uncertain'), 'none', 'Ordrehandlingen blev verificeret og udført.'); - if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false - || $db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask-versionen kunne ikke opdateres.'); - } - $this->recordAudit($context, 'action_executed', $action, $suggestion, $actorId, [ - 'matched_order_id' => $matchedOrderId, - 'created_order_id' => $createdOrderId, - 'status' => $status, - ]); - - return $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; - } - - private function isCriticalAutomaticInvariantFailure(string $error): bool - { - $error = strtolower($error); - foreach (['allerede tilknyttet', 'unik wash_id', 'atomisk', 'udenfor', 'mangler en gyldig hall', 'ændret efter evalueringen'] as $needle) { - if (str_contains($error, $needle)) { - return true; - } - } - return false; - } - - private function automaticFeedbackReason(array $suggestion, array $context): string - { - if ($this->isExactAttachSuggestionForContext($suggestion, $context)) { - return 'Automatisk accepteret: Prisoverensstemmelse.'; - } - - return 'Automatisk accepteret.'; - } - - private function createOrderFromContext(array $context): orders_o - { - global $db; - $orderData = $context['proposed_order']; - $items = $context['items']; - $connection = $db->conn(); - $orderStatement = $connection->prepare( - 'INSERT INTO orders - (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, wash_id, lane, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - if ($orderStatement === false) { - throw new Exception('Kunne ikke forberede sikker ordreoprettelse.'); - } - $customerId = (int)$orderData['customer_id']; - $cashierId = self::AUTOMATION_CASHIER_ID; - $reference = (string)($orderData['reference'] ?? ''); - $notes = (string)($orderData['notes'] ?? ''); - $departmentId = (int)$orderData['department_id']; - $reg1 = (string)($orderData['reg_1'] ?? ''); - $reg2 = (string)($orderData['reg_2'] ?? ''); - $reg3 = (string)($orderData['reg_3'] ?? ''); - $washId = (string)$context['wash_id']; - $lane = (int)($orderData['lane'] ?? 0); - $createdAt = (string)($orderData['created_at'] ?? date('Y-m-d H:i:s')); - $orderStatement->bind_param( - 'iississssis', - $customerId, - $cashierId, - $reference, - $notes, - $departmentId, - $reg1, - $reg2, - $reg3, - $washId, - $lane, - $createdAt - ); - if (!$orderStatement->execute() || $orderStatement->affected_rows !== 1) { - throw new Exception('Ordren kunne ikke oprettes atomisk.'); - } - $orderId = (int)$connection->insert_id; - $orderStatement->close(); - - $itemStatement = $connection->prepare( - 'INSERT INTO order_items - (order_id, product_id, reference, notes, cashier_id, price, quantity, related_item_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)' - ); - if ($itemStatement === false) { - throw new Exception('Kunne ikke forberede sikker ordrelinjeoprettelse.'); - } - $firstItemId = null; - foreach ($items as $item) { - $productId = (int)$item['product_id']; - $itemReference = (string)($item['reference'] ?? ''); - $itemNotes = (string)($item['notes'] ?? ''); - $price = (int)$item['price']; - $quantity = (int)$item['quantity']; - $relatedItemId = $firstItemId; - $itemStatement->bind_param( - 'iissiiii', - $orderId, - $productId, - $itemReference, - $itemNotes, - $cashierId, - $price, - $quantity, - $relatedItemId - ); - if (!$itemStatement->execute() || $itemStatement->affected_rows !== 1) { - throw new Exception('En ordrelinje kunne ikke oprettes atomisk.'); - } - if ($firstItemId === null) { - $firstItemId = (int)$connection->insert_id; - } - } - $itemStatement->close(); - $verificationResult = $db->query( - "SELECT COUNT(*) item_count, COALESCE(SUM(price * quantity), 0) item_total - FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL" - ); - if ($verificationResult === false) { - throw new Exception('Ordren kunne ikke efterkontrolleres.'); - } - $verification = $db->fetch_assoc($verificationResult); - if ((int)($verification['item_count'] ?? 0) !== count($items) - || (int)($verification['item_total'] ?? 0) !== $this->itemsTotal($items)) { - throw new Exception('Den oprettede ordre kunne ikke afstemmes.'); - } - - $order = (new orders_o())->select($orderId); - $order->assignToInvoiceCollection(null, false); - $order->objectChanged(); - return $order; - } - - private function buildContext(int $usageLogId, xlvask_usage_log $log, array $row = []): array - { - $simulated = (new orders_o())->simulateOrderFromXLVask($log, true); - $proposedOrder = $simulated['order'] ?? []; - $items = $simulated['order_items'] ?? []; - $signature = $this->buildSignature($log, $proposedOrder, $items); - $signatureJson = json_encode($signature, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($signatureJson === false) { - throw new Exception('Kunne ikke opbygge signatur for XL Vask-vasken.'); - } - - return [ - 'usage_log_id' => $usageLogId, - 'wash_id' => (string)$log->WashId, - 'hall_id' => trim((string)$log->HallId), - 'log' => $log, - 'proposed_order' => $proposedOrder, - 'items' => $items, - 'total' => $this->itemsTotal($items), - 'signature' => $signature, - 'signature_json' => $signatureJson, - 'signature_hash' => hash('sha256', $signatureJson), - 'source_hash' => (string)($row['source_hash'] ?? ''), - 'source_revision' => (string)($row['source_revision'] ?? $log->Updated ?? ''), - 'source_observed_at' => $row['source_observed_at'] ?? null, - 'source_stable_since' => $row['source_stable_since'] ?? null, - 'source_observation_count' => (int)($row['source_observation_count'] ?? 0), - 'expected_version' => (int)($row['expected_version'] ?? 1), - 'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600), - 'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder), - ]; - } - - private function contextGuardReason(array $context): ?string - { - if ((int)($context['proposed_order']['customer_id'] ?? 0) < 1) { - return 'Vasken mangler en gyldig kundemapping.'; - } - - if ((int)($context['proposed_order']['department_id'] ?? 0) < 1) { - return 'Vasken mangler en gyldig afdelingsmapping.'; - } - - if (!is_array($context['items'] ?? null) || count($context['items']) < 1) { - return 'Vasken mangler gyldige produkter.'; - } - - foreach ($context['items'] as $item) { - if (!is_array($item) || (int)($item['product_id'] ?? 0) < 1) { - return 'Vasken mangler gyldige produkter.'; - } - } - - return null; - } - - private function buildSignature(xlvask_usage_log $log, array $proposedOrder, array $items): array - { - return [ - 'registration' => $this->normalizeRegistration((string)$log->RegistrationNumber), - 'customer_number' => (int)$log->CustomerId, - 'department_id' => (int)($proposedOrder['department_id'] ?? 0), - 'lane' => (int)($proposedOrder['lane'] ?? 0), - 'primary_product_id' => (int)($items[0]['product_id'] ?? 0), - 'items' => $this->itemSignatureParts($items), - 'total_net_amount' => $this->itemsTotal($items), - ]; - } - - private function scoreOrderMatch(array $usageItems, array $orderItems): array - { - return self::scoreItemMatchForAutomation($usageItems, $orderItems); - } - - private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder, bool $forUpdate = false): array - { - global $db; - - $registration = $db->escape_string($this->normalizeRegistration((string)$log->RegistrationNumber)); - $rawRegistration = $db->escape_string(trim((string)$log->RegistrationNumber)); - $customerNumber = (int)$log->CustomerId; - $departmentId = (int)($proposedOrder['department_id'] ?? 0); - $date = date('Y-m-d', strtotime((string)$log->StartTime)); - $from = $db->escape_string($date . ' 00:00:00'); - $to = $db->escape_string($date . ' 23:59:59'); - - if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { - return []; - } - - $sql = "SELECT * - FROM orders - WHERE deleted_at IS NULL - AND customer_id = {$customerNumber} - AND department_id = {$departmentId} - AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . " - AND created_at BETWEEN '{$from}' AND '{$to}' - AND (wash_id IS NULL OR wash_id = '') - AND COALESCE(invoice_collection_id, 0) = 0 - AND COALESCE(booking_id, 0) = 0 - AND ( - REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}') - OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}') - OR REPLACE(UPPER(reg_3), ' ', '') IN ('{$registration}', '{$rawRegistration}') - ) - ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC - LIMIT 20" . ($forUpdate ? ' FOR UPDATE' : ''); - - $rows = $db->fetch_all($db->query($sql)); - if ($forUpdate && $rows !== []) { - $ids = array_values(array_filter(array_map(static fn(array $row): int => (int)($row['id'] ?? 0), $rows))); - if ($ids !== []) { - $db->query('SELECT id FROM order_items WHERE order_id IN (' . implode(',', $ids) . ') FOR UPDATE'); - } - } - return array_map(function (array $row): array { - $orderItems = (new orders_o())->getOrderItems((int)$row['id']); - return [ - ...$row, - 'id' => (int)$row['id'], - 'total_net_amount' => (int)($row['total_net_amount'] ?? $this->itemsTotal($orderItems)), - 'order_items' => $orderItems, - ]; - }, $rows); - } - - private function candidateFromContext(array $context, int $orderId): ?array - { - foreach ((array)($context['candidate_orders'] ?? []) as $candidate) { - if (is_array($candidate) && (int)($candidate['id'] ?? 0) === $orderId) { - return $candidate; - } - } - return null; - } - - private function findMatchingHistoricalOrder(array $context): ?array - { - global $db; - - $signature = $context['signature']; - $registration = $db->escape_string((string)$signature['registration']); - $customerNumber = (int)$signature['customer_number']; - $departmentId = (int)$signature['department_id']; - $createdBefore = $db->escape_string((string)($context['proposed_order']['created_at'] ?? date('Y-m-d H:i:s'))); - - if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { - return null; - } - - $sql = "SELECT * - FROM orders - WHERE deleted_at IS NULL - AND customer_id = {$customerNumber} - AND department_id = {$departmentId} - AND created_at < '{$createdBefore}' - AND ( - REPLACE(UPPER(reg_1), ' ', '') = '{$registration}' - OR REPLACE(UPPER(reg_2), ' ', '') = '{$registration}' - OR REPLACE(UPPER(reg_3), ' ', '') = '{$registration}' - ) - ORDER BY created_at DESC - LIMIT 10"; - - foreach ($db->fetch_all($db->query($sql)) as $row) { - $orderItems = (new orders_o())->getOrderItems((int)$row['id']); - if ($this->itemSignatureParts($orderItems) === $signature['items']) { - return [ - ...$row, - 'id' => (int)$row['id'], - 'order_items' => $orderItems, - ]; - } - } - - return null; - } - - private function existingLinkedOrder(xlvask_usage_log $log): ?orders_o - { - return (new orders_o())->selectByWashId($log->WashId); - } - - private function guardReason(xlvask_usage_log $log, bool $checkExistingLink = true): ?string - { - if (!empty($log->ignored_at)) { - return 'Vasken er ignoreret.'; - } - - if (!$log->isCompleted()) { - return 'Vasken er ikke afsluttet.'; - } - - if (!$log->hasBillableCustomer()) { - return 'Vasken mangler en fakturerbar kunde.'; - } - - if ($checkExistingLink && $this->existingLinkedOrder($log) !== null) { - return 'Vasken er allerede tilknyttet en ordre.'; - } - - return null; - } - - private function persistSuggestion(array $context, array $suggestion, ?int $actorId): int - { - global $db; - - $existing = $this->latestActionableSuggestion((int)$context['usage_log_id']); - if ($existing !== null) { - if ((string)($existing['source'] ?? '') === self::SOURCE_OPENAI - || (string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI) { - $db->query( - "UPDATE xlvask_automation_suggestions SET status = 'superseded', updated_at = NOW() - WHERE id = " . (int)$existing['id'] . " AND status = 'suggested'" - ); - } else { - $this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId); - return (int)$existing['id']; - } - } - - $fields = [ - 'usage_log_id' => (int)$context['usage_log_id'], - 'run_id' => $this->runId, - 'wash_id' => (string)$context['wash_id'], - 'signature_hash' => (string)$context['signature_hash'], - 'signature_json' => (string)$context['signature_json'], - 'action' => (string)$suggestion['action'], - 'status' => self::STATUS_SUGGESTED, - 'confidence' => (float)$suggestion['confidence'], - 'source' => (string)$suggestion['source'], - 'policy_version' => self::POLICY_VERSION, - 'planner_identity_hash' => (string)($suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash']), - 'model' => $suggestion['model'] ?? null, - 'model_confidence' => $suggestion['model_confidence'] ?? null, - 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, - 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), - 'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'expected_version' => (int)($context['expected_version'] ?? 1), - 'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']), - 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], - 'created_order_id' => null, - 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'reason' => (string)$suggestion['reason'], - 'created_by' => $actorId, - ]; - - $columns = []; - $values = []; - foreach ($fields as $column => $value) { - $columns[] = "`{$column}`"; - if ($value === null) { - $values[] = 'NULL'; - } elseif (is_int($value) || is_float($value)) { - $values[] = (string)$value; - } else { - $values[] = "'" . $db->escape_string((string)$value) . "'"; - } - } - - $db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'); - $suggestionId = (int)$db->insert_id(); - $this->syncUsageState( - (int)$context['usage_log_id'], - 'needs_review', - (string)($suggestion['certainty'] ?? 'uncertain'), - (string)$suggestion['action'], - (string)$suggestion['reason'] - ); - return $suggestionId; - } - - private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void - { - global $db; - - $fields = [ - 'run_id' => $this->runId, - 'signature_hash' => (string)$context['signature_hash'], - 'signature_json' => (string)$context['signature_json'], - 'action' => (string)$suggestion['action'], - 'confidence' => (float)$suggestion['confidence'], - 'source' => (string)$suggestion['source'], - 'policy_version' => self::POLICY_VERSION, - 'planner_identity_hash' => (string)($suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash']), - 'model' => $suggestion['model'] ?? null, - 'model_confidence' => $suggestion['model_confidence'] ?? null, - 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, - 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), - 'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'expected_version' => (int)($context['expected_version'] ?? 1), - 'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']), - 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], - 'created_order_id' => null, - 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'reason' => (string)$suggestion['reason'], - ]; - - if ($actorId !== null) { - $fields['created_by'] = $actorId; - } - - $assignments = []; - foreach ($fields as $column => $value) { - if ($value === null) { - $sqlValue = 'NULL'; - } elseif (is_int($value) || is_float($value)) { - $sqlValue = (string)$value; - } else { - $sqlValue = "'" . $db->escape_string((string)$value) . "'"; - } - $assignments[] = "`{$column}` = {$sqlValue}"; - } - - $db->query( - 'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) . - " WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'" - ); - $this->syncUsageState( - (int)$context['usage_log_id'], - 'needs_review', - (string)($suggestion['certainty'] ?? 'uncertain'), - (string)$suggestion['action'], - (string)$suggestion['reason'] - ); - } - - private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void - { - global $db; - - $values = [ - 'usage_log_id' => (int)$context['usage_log_id'], - 'wash_id' => (string)$context['wash_id'], - 'signature_hash' => (string)$context['signature_hash'], - 'signature_json' => (string)$context['signature_json'], - 'action' => $action, - 'decision' => $decision, - 'order_id' => $orderId > 0 ? $orderId : null, - 'reason' => $reason, - 'created_by' => $actorId, - ]; - - $columns = []; - $sqlValues = []; - foreach ($values as $column => $value) { - $columns[] = "`{$column}`"; - if ($value === null) { - $sqlValues[] = 'NULL'; - } elseif (is_int($value)) { - $sqlValues[] = (string)$value; - } else { - $sqlValues[] = "'" . $db->escape_string((string)$value) . "'"; - } - } - - $db->query('INSERT INTO xlvask_automation_feedback (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $sqlValues) . ')'); - } - - private function loadOpenAiCacheResult(string $cacheKey): ?array - { - global $db; - - $cacheKey = $db->escape_string($cacheKey); - $result = $db->query( - "SELECT result_json FROM xlvask_automation_openai_cache - WHERE cache_key = '{$cacheKey}' - LIMIT 1" - ); - if ($result === false || $result->num_rows < 1) { - return null; - } - - $row = $db->fetch_assoc($result); - $decoded = json_decode((string)($row['result_json'] ?? ''), true); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { - return null; - } - - if (!$this->readOnlyEvaluation) { - $db->query( - "UPDATE xlvask_automation_openai_cache - SET hits = hits + 1, last_hit_at = NOW() - WHERE cache_key = '{$cacheKey}'" - ); - } - - return $decoded; - } - - private function persistOpenAiCacheResult( - string $cacheKey, - string $schemaName, - array $payload, - array $schema, - string $prompt, - float $temperature, - array $result - ): void { - global $db; - - $input = [ - 'version' => self::OPENAI_CACHE_VERSION, - 'schema_name' => $schemaName, - 'prompt' => $prompt, - 'payload' => $payload, - 'schema' => $schema, - 'temperature' => round($temperature, 4), - ]; - - // Do not retain the personal operational prompt payload in the cache. - $inputJson = self::stableJsonForAutomation([ - 'version' => self::OPENAI_CACHE_VERSION, - 'schema_name' => $schemaName, - 'input_hash' => hash('sha256', self::stableJsonForAutomation($input)), - ]); - $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); - if ($resultJson === false) { - return; - } - - $cacheKey = $db->escape_string($cacheKey); - $schemaName = $db->escape_string($schemaName); - $inputJson = $db->escape_string($inputJson); - $resultJson = $db->escape_string($resultJson); - - $db->query( - "INSERT INTO xlvask_automation_openai_cache - (cache_key, schema_name, input_json, result_json) - VALUES - ('{$cacheKey}', '{$schemaName}', '{$inputJson}', '{$resultJson}') - ON DUPLICATE KEY UPDATE - result_json = VALUES(result_json), - input_json = VALUES(input_json), - updated_at = NOW()" - ); - $db->query("DELETE FROM xlvask_automation_openai_cache WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"); - } - - private function hasAcceptedFeedback(string $signatureHash, string $action): bool - { - return $this->hasFeedbackDecision($signatureHash, $action, 'accepted'); - } - - private function hasDeniedFeedback(string $signatureHash, string $action): bool - { - return $this->hasFeedbackDecision($signatureHash, $action, 'denied'); - } - - private function hasFeedbackDecision(string $signatureHash, string $action, string $decision): bool - { - global $db; - $signatureHash = $db->escape_string($signatureHash); - $action = $db->escape_string($action); - $decision = $db->escape_string($decision); - $result = $db->query( - "SELECT id FROM xlvask_automation_feedback - WHERE signature_hash = '{$signatureHash}' AND action = '{$action}' AND decision = '{$decision}' - ORDER BY id DESC LIMIT 1" - ); - return $result !== false && $result->num_rows > 0; - } - - private function latestTerminalSuggestion(int $usageLogId): ?array - { - return $this->latestSuggestionWhere($usageLogId, [ - self::STATUS_SUGGESTED, - self::STATUS_AUTO_ACCEPTED, - self::STATUS_ACCEPTED, - self::STATUS_DENIED, - ]); - } - - private function latestActionableSuggestion(int $usageLogId): ?array - { - return $this->latestSuggestionWhere($usageLogId, [self::STATUS_SUGGESTED]); - } - - private function latestSuggestionWhere(int $usageLogId, array $statuses): ?array - { - global $db; - $statusSql = implode(',', array_map(fn(string $status): string => "'" . $db->escape_string($status) . "'", $statuses)); - $result = $db->query( - "SELECT * FROM xlvask_automation_suggestions - WHERE usage_log_id = {$usageLogId} AND status IN ({$statusSql}) - ORDER BY id DESC LIMIT 1" - ); - if ($result === false || $result->num_rows < 1) { - return null; - } - - return $db->fetch_assoc($result); - } - - private function loadSuggestion(int $suggestionId): ?array - { - global $db; - $result = $db->query("SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"); - if ($result === false || $result->num_rows < 1) { - return null; - } - return $db->fetch_assoc($result); - } - - private function updateSuggestionStatus(int $suggestionId, string $status, ?int $actorId): void - { - global $db; - $status = $db->escape_string($status); - $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; - if ($db->query( - "UPDATE xlvask_automation_suggestions - SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW() - WHERE id = {$suggestionId}" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask suggestion execution state could not be updated atomically.'); - } - } - - private function updateSuggestionExecution( - int $suggestionId, - string $status, - ?int $actorId, - ?int $matchedOrderId, - ?int $createdOrderId, - string $action, - string $certainty, - string|false|null $candidateOrderJson - ): void - { - global $db; - $status = $db->escape_string($status); - $action = $db->escape_string($action); - $certainty = $db->escape_string($certainty); - $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; - $matchedSql = $matchedOrderId === null ? 'matched_order_id' : (string)(int)$matchedOrderId; - $createdSql = $createdOrderId === null ? 'created_order_id' : (string)(int)$createdOrderId; - $candidateSql = $candidateOrderJson === false || $candidateOrderJson === null - ? 'candidate_order_json' - : "'" . $db->escape_string($candidateOrderJson) . "'"; - if ($db->query( - "UPDATE xlvask_automation_suggestions - SET status = '{$status}', - decided_by = {$actorSql}, - decided_at = NOW(), - executed_at = NOW(), - action = '{$action}', - certainty = '{$certainty}', - matched_order_id = {$matchedSql}, - created_order_id = {$createdSql}, - candidate_order_json = {$candidateSql} - WHERE id = {$suggestionId}" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask suggestion execution state could not be updated atomically.'); - } - } - - private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void - { - global $db; - $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; - $message = $db->escape_string($message); - $db->query( - "UPDATE xlvask_automation_suggestions - SET status = '" . self::STATUS_FAILED . "', - reason = CONCAT(COALESCE(reason, ''), ' Fejl: {$message}'), - decided_by = {$actorSql}, - decided_at = NOW() - WHERE id = {$suggestionId}" - ); - $suggestion = $this->loadSuggestion($suggestionId); - if ($suggestion !== null) { - $this->syncUsageState((int)$suggestion['usage_log_id'], 'failed', 'none', 'none', $message); - } - } - - private function syncUsageState( - int $usageLogId, - string $resolutionState, - string $certainty, - string $plannedAction, - string $reason - ): void { - global $db; - $resolutionState = $db->escape_string($resolutionState); - $certainty = $db->escape_string($certainty); - $plannedAction = $db->escape_string($plannedAction); - $reason = $db->escape_string($reason); - $runSql = $this->runId === null ? 'last_run_id' : (string)$this->runId; - if ($db->query( - "UPDATE xlvask_usage_logs SET resolution_state = '{$resolutionState}', certainty = '{$certainty}', - planned_action = '{$plannedAction}', state_reason = '{$reason}', last_run_id = {$runSql}, - last_evaluated_at = NOW() WHERE id = {$usageLogId}" - ) === false) { - throw new Exception('The XL Vask usage-log state could not be updated atomically.'); - } - } - - private function recordAudit( - array $context, - string $eventType, - ?string $action, - array $before, - ?int $actorId, - array $after = [] - ): void { - global $db; - $runSql = $this->runId === null ? 'NULL' : (string)$this->runId; - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - $safeBefore = $this->minimalAuditSuggestion($before); - $safeAfter = $this->minimalAuditSuggestion($after); - $beforeJson = $db->escape_string(json_encode($safeBefore, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $afterJson = $db->escape_string(json_encode($safeAfter, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $evidenceJson = $db->escape_string(json_encode($safeBefore['evidence_codes'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'); - if ($db->query( - "INSERT INTO xlvask_automation_audit - (run_id, usage_log_id, wash_id, event_type, action, policy_version, input_hash, - source_revision, expected_version, before_json, after_json, evidence_json, actor_id) - VALUES ({$runSql}, " . (int)$context['usage_log_id'] . ", '" . $db->escape_string((string)$context['wash_id']) . "', - '" . $db->escape_string($eventType) . "', " . ($action === null ? 'NULL' : "'" . $db->escape_string($action) . "'") . ", - '" . self::POLICY_VERSION . "', '" . $db->escape_string((string)($context['source_hash'] ?? $context['signature_hash'])) . "', - '" . $db->escape_string((string)($context['source_revision'] ?? '')) . "', " . (int)($context['expected_version'] ?? 1) . ", - '{$beforeJson}', '{$afterJson}', '{$evidenceJson}', {$actorSql})" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask automation audit record could not be stored atomically.'); - } - } - - private function minimalAuditSuggestion(array $value): array - { - $minimal = array_intersect_key($value, array_flip([ - 'id', 'status', 'action', 'certainty', 'source', 'matched_order_id', 'created_order_id', - 'policy_version', 'input_hash', 'expected_version', - ])); - $minimal['evidence_codes'] = array_values(array_filter(array_map( - static fn(mixed $evidence): string => is_array($evidence) - ? mb_substr((string)($evidence['type'] ?? ''), 0, 64) - : mb_substr((string)$evidence, 0, 64), - (array)($value['evidence'] ?? []) - ))); - return $minimal; - } - - private function loadUsageLogRow(int $usageLogId): ?array - { - global $db; - (new xlvask_usage_logs_o())->structure(); - $result = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} LIMIT 1"); - if ($result === false || $result->num_rows < 1) { - return null; - } - - return $db->fetch_assoc($result); - } - - private function loadUsageLogRowsByIds(array $ids, array $allowedHallIds = []): array - { - global $db; - $ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0)); - if ($ids === []) { - return []; - } - - (new xlvask_usage_logs_o())->structure(); - $hallSql = $this->hallScopeSql($allowedHallIds); - $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')' . $hallSql); - return $db->fetch_all($result); - } - - private function updateUsageLogAutomationState(int $usageLogId, array $automation, ?int $runId): void - { - global $db; - if ($usageLogId < 1) { - return; - } - - $state = $this->stateForAutomationResult($automation); - $reason = $db->escape_string((string)($automation['reason'] ?? $automation['error'] ?? '')); - $runSql = $runId === null ? 'last_run_id' : (string)(int)$runId; - $db->query( - "UPDATE xlvask_usage_logs SET - resolution_state = '" . $db->escape_string($state['resolution_state']) . "', - certainty = '" . $db->escape_string($state['certainty']) . "', - planned_action = '" . $db->escape_string($state['planned_action']) . "', - state_reason = " . ($reason === '' ? 'NULL' : "'{$reason}'") . ", - last_run_id = {$runSql}, - last_evaluated_at = NOW() - WHERE id = {$usageLogId}" - ); - } - - private function stateForAutomationResult(array $automation): array - { - $status = (string)($automation['status'] ?? self::STATUS_NONE); - $action = (string)($automation['action'] ?? self::ACTION_NONE); - $validatedCertainty = (string)($automation['certainty'] ?? 'none'); - if (!in_array($validatedCertainty, ['certain', 'uncertain', 'none'], true)) { - $validatedCertainty = 'none'; - } - if ((string)($automation['resolution_state'] ?? '') === 'needs_review') { - return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck']; - } - - if ($status === self::STATUS_AUTO_ACCEPTED) { - return [ - 'resolution_state' => $action === self::ACTION_CREATE ? 'auto_created' : 'auto_linked', - 'certainty' => 'certain', - 'planned_action' => $action, - ]; - } - if ($status === self::STATUS_ACCEPTED) { - return ['resolution_state' => 'already_linked', 'certainty' => $validatedCertainty, 'planned_action' => $action]; - } - if ($status === self::STATUS_DENIED) { - return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck']; - } - if ($status === self::STATUS_FAILED) { - return ['resolution_state' => 'failed', 'certainty' => 'none', 'planned_action' => 'recheck']; - } - if ($status === self::STATUS_SUGGESTED) { - return [ - 'resolution_state' => 'needs_review', - 'certainty' => $validatedCertainty === 'certain' ? 'certain' : 'uncertain', - 'planned_action' => in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) ? $action : 'none', - ]; - } - - return ['resolution_state' => 'needs_review', 'certainty' => 'none', 'planned_action' => 'none']; - } - - private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit, array $allowedHallIds = []): array - { - global $db; - (new xlvask_usage_logs_o())->structure(); - $where = $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds); - $limit = max(1, min(500, $limit)); - $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) - . " ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC LIMIT {$limit}"); - return $db->fetch_all($result); - } - - private function countPendingRows(?string $dateFrom, ?string $dateTo, array $allowedHallIds): int - { - global $db; - $row = $db->fetch_assoc($db->query( - 'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE ' - . implode(' AND ', $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds)) - )); - return (int)($row['total'] ?? 0); - } - - private function pendingWhere(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array - { - global $db; - $start = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"; - $where = [ - 'FinishStatus = 1', - '(ignored_at IS NULL OR ignored_at = "")', - "resolution_state NOT IN ('already_linked', 'auto_linked', 'auto_created', 'ignored')", - ]; - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($allowedHallIds !== []) { - $where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; - } - $where[] = $dateFrom !== null && strtotime($dateFrom) !== false - ? "{$start} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'" - : "{$start} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; - if ($dateTo !== null && strtotime($dateTo) !== false) { - $where[] = "{$start} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; - } - return $where; - } - - private function hallScopeSql(array $allowedHallIds): string - { - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - return $allowedHallIds === [] ? '' : ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; - } - - private function normalizeHallIds(array $hallIds): array - { - return array_values(array_unique(array_filter(array_map( - static fn(mixed $id): string => trim((string)$id), - $hallIds - ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191))); - } - - private function quotedHallIds(array $hallIds): string - { - global $db; - return implode(',', array_map( - static fn(string $id): string => "'" . $db->escape_string($id) . "'", - $hallIds - )); - } - - private function usageLogFromRow(array $row): xlvask_usage_log - { - $row = self::normalizeUsageLogRowForAutomation($row); - - $xlvask = new xlvask(); - return $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($row); - } - - private function formatSuggestion(array $row): array - { - $status = (string)($row['status'] ?? self::STATUS_NONE); - $action = (string)($row['action'] ?? self::ACTION_NONE); - $candidateOrder = $this->decodeJsonField($row['candidate_order_json'] ?? null); - $hasBoundAttachCandidate = $action === self::ACTION_ATTACH - && (int)($row['matched_order_id'] ?? 0) > 0 - && is_array($candidateOrder) - && (int)($candidateOrder['id'] ?? 0) === (int)$row['matched_order_id']; - $suggestedAction = $status === self::STATUS_SUGGESTED - && in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true); - $actionReview = in_array($status, [self::STATUS_SUGGESTED, self::STATUS_AUTO_ACCEPTED], true) - && (string)($row['source'] ?? '') === self::SOURCE_OPENAI - && isset($row['id']) - ? $this->adjudicationState((int)$row['id']) - : null; - return [ - 'id' => isset($row['id']) ? (int)$row['id'] : null, - 'status' => $status, - 'action' => $action, - 'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0, - 'source' => (string)($row['source'] ?? ''), - 'certainty' => (string)($row['certainty'] ?? 'uncertain'), - 'calibrated_probability' => isset($row['calibrated_probability']) && $row['calibrated_probability'] !== null - ? (float)$row['calibrated_probability'] : null, - 'model' => $row['model'] ?? null, - 'model_confidence' => isset($row['model_confidence']) && $row['model_confidence'] !== null - ? (float)$row['model_confidence'] : null, - 'policy_version' => (string)($row['policy_version'] ?? self::POLICY_VERSION), - 'planner_identity_hash' => $row['planner_identity_hash'] ?? null, - 'evidence' => $this->decodeJsonField($row['evidence_json'] ?? null) ?? [], - 'contradictions' => $this->decodeJsonField($row['contradictions_json'] ?? null) ?? [], - 'risk_flags' => $this->decodeJsonField($row['risk_flags_json'] ?? null) ?? [], - 'plan_steps' => $this->decodeJsonField($row['plan_steps_json'] ?? null) ?? [], - 'expected_version' => isset($row['expected_version']) ? (int)$row['expected_version'] : null, - 'run_id' => isset($row['run_id']) && $row['run_id'] !== null ? (int)$row['run_id'] : null, - 'reason' => (string)($row['reason'] ?? ''), - 'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null, - 'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null, - 'candidate_order' => $candidateOrder, - 'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null), - 'can_accept' => $suggestedAction, - 'can_deny' => $suggestedAction, - 'can_ignore' => $suggestedAction, - 'can_attach_order' => $suggestedAction && $hasBoundAttachCandidate, - 'can_create_order' => $suggestedAction && $action === self::ACTION_CREATE, - 'review_eligible' => $suggestedAction, - 'adjudication_eligible' => $actionReview !== null && empty($actionReview['reviewed_at']), - 'allowed_adjudication_outcomes' => $actionReview !== null && empty($actionReview['reviewed_at']) - ? ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'] : [], - 'adjudication_outcome' => $actionReview['review_outcome'] ?? null, - 'adjudicated_at' => $actionReview['reviewed_at'] ?? null, - 'adjudicated_by' => isset($actionReview['reviewed_by']) && $actionReview['reviewed_by'] !== null - ? (int)$actionReview['reviewed_by'] : null, - ]; - } - - private function formatTransientSuggestion(int $usageLogId, array $suggestion): array - { - return [ - 'usage_log_id' => $usageLogId, - 'id' => null, - 'status' => self::STATUS_SUGGESTED, - 'action' => (string)($suggestion['action'] ?? self::ACTION_NONE), - 'confidence' => (float)($suggestion['confidence'] ?? 0), - 'source' => (string)($suggestion['source'] ?? ''), - 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), - 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, - 'model' => $suggestion['model'] ?? null, - 'model_confidence' => $suggestion['model_confidence'] ?? null, - 'policy_version' => self::POLICY_VERSION, - 'planner_identity_hash' => $suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash'], - 'reason' => (string)($suggestion['reason'] ?? ''), - 'matched_order_id' => $suggestion['matched_order_id'] ?? null, - 'created_order_id' => null, - 'candidate_order' => $suggestion['candidate_order'] ?? null, - 'proposed_order' => $suggestion['proposed_order'] ?? null, - 'evidence' => $suggestion['evidence'] ?? [], - 'contradictions' => $suggestion['contradictions'] ?? [], - 'risk_flags' => $suggestion['risk_flags'] ?? [], - 'plan_steps' => $suggestion['plan_steps'] ?? [], - 'expected_version' => $suggestion['expected_version'] ?? null, - 'run_id' => $this->runId, - 'can_accept' => false, - 'can_deny' => false, - 'can_ignore' => false, - 'can_attach_order' => false, - 'can_create_order' => false, - 'review_eligible' => false, - 'adjudication_eligible' => false, - 'allowed_adjudication_outcomes' => [], - 'adjudication_outcome' => null, - 'adjudicated_at' => null, - 'adjudicated_by' => null, - ]; - } - - private function emptyAutomation(string $reason = ''): array - { - return [ - 'id' => null, - 'status' => self::STATUS_NONE, - 'action' => self::ACTION_NONE, - 'confidence' => 0.0, - 'source' => '', - 'certainty' => 'none', - 'calibrated_probability' => null, - 'model' => null, - 'model_confidence' => null, - 'policy_version' => self::POLICY_VERSION, - 'planner_identity_hash' => null, - 'evidence' => [], - 'contradictions' => [], - 'risk_flags' => [], - 'plan_steps' => [], - 'expected_version' => null, - 'run_id' => null, - 'reason' => $reason, - 'matched_order_id' => null, - 'created_order_id' => null, - 'candidate_order' => null, - 'proposed_order' => null, - 'can_accept' => false, - 'can_deny' => false, - 'can_ignore' => false, - 'can_attach_order' => false, - 'can_create_order' => false, - 'review_eligible' => false, - 'adjudication_eligible' => false, - 'allowed_adjudication_outcomes' => [], - 'adjudication_outcome' => null, - 'adjudicated_at' => null, - 'adjudicated_by' => null, - ]; - } - - private function readProjectionActionFlags(array $suggestion, array $usageRow): array - { - $eligible = self::suggestionMatchesCurrentUsageForReview($suggestion, $usageRow); - $action = (string)($suggestion['action'] ?? self::ACTION_NONE); - // List projection is revision-only and query bounded. Current candidate - // reconstruction is deferred to preview/apply, where it is authoritative. - $canAttach = $eligible - && $action === self::ACTION_ATTACH - && (int)($suggestion['matched_order_id'] ?? 0) > 0; - - return [ - 'can_accept' => $eligible && ($action !== self::ACTION_ATTACH || $canAttach), - 'can_deny' => $eligible, - 'can_ignore' => $eligible, - 'can_attach_order' => $canAttach, - 'can_create_order' => $eligible && $action === self::ACTION_CREATE, - 'review_eligible' => $eligible, - ]; - } - - private function decodeJsonField(?string $value): mixed - { - if ($value === null || $value === '') { - return null; - } - - $decoded = json_decode($value, true); - return json_last_error() === JSON_ERROR_NONE ? $decoded : null; - } - - private function adjudicationState(int $suggestionId): ?array - { - global $db; - try { - $result = $db->query( - "SELECT review_outcome, reviewed_by, reviewed_at - FROM xlvask_automation_action_events WHERE suggestion_id = {$suggestionId} LIMIT 1" - ); - if ($result !== false && $result->num_rows > 0) { - return $db->fetch_assoc($result); - } - $label = $db->query( - "SELECT COALESCE(adjudication_outcome, outcome) review_outcome, - adjudicated_by reviewed_by, adjudicated_at reviewed_at - FROM xlvask_automation_calibration_label_events - WHERE suggestion_id = {$suggestionId} ORDER BY id DESC LIMIT 1" - ); - if ($label !== false && $label->num_rows > 0) { - return $db->fetch_assoc($label); - } - return ['review_outcome' => null, 'reviewed_by' => null, 'reviewed_at' => null]; - } catch (\Throwable) { - return null; - } - } - - private function itemSignatureParts(array $items): array - { - return self::itemSignaturePartsForAutomation($items); - } - - private function compactItems(array $items): array - { - return array_map(fn(array $item): array => [ - 'product_id' => (int)($item['product_id'] ?? 0), - 'quantity' => (int)($item['quantity'] ?? 0), - 'price' => (int)($item['price'] ?? 0), - ], $items); - } - - private function itemsTotal(array $items): int - { - return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); - } - - private function productOverlap(array $usageItems, array $orderItems): float - { - $usageBag = $this->productBag($usageItems); - $orderBag = $this->productBag($orderItems); - $usageTotal = array_sum($usageBag); - if ($usageTotal <= 0) { - return 0.0; - } - - $overlap = 0; - foreach ($usageBag as $productId => $quantity) { - $overlap += min($quantity, $orderBag[$productId] ?? 0); - } - - return $overlap / $usageTotal; - } - - private function productBag(array $items): array - { - $bag = []; - foreach ($items as $item) { - $productId = (int)($item['product_id'] ?? 0); - if ($productId < 1) { - continue; - } - $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); - } - - return $bag; - } - - private function normalizeRegistration(string $registration): string - { - return self::normalizeRegistrationForAutomation($registration); - } - - private function resetAiUsage(): void - { - $this->aiUsage = [ - 'requests' => 0, - 'cache_hits' => 0, - 'input_tokens' => 0, - 'output_tokens' => 0, - 'total_tokens' => 0, - 'estimated_cost_usd' => 0.0, - 'budget_exhausted' => false, - 'stop_reason' => null, - ]; - } - - private function openAiBudgetReached(): bool - { - if ($this->aiUsage['budget_exhausted']) { - return true; - } - if ($this->aiUsage['requests'] >= (int)$this->aiPolicy['batch_size']) { - $this->aiUsage['budget_exhausted'] = true; - $this->aiUsage['stop_reason'] = 'ai_batch_size_reached'; - return true; - } - $maxCost = $this->aiPolicy['max_cost_usd']; - if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) { - $this->aiUsage['budget_exhausted'] = true; - $this->aiUsage['stop_reason'] = 'ai_cost_limit_reached'; - return true; - } - return false; - } - - private function recordOpenAiUsage(array $usage): void - { - $inputTokens = max(0, (int)($usage['input_tokens'] ?? 0)); - $outputTokens = max(0, (int)($usage['output_tokens'] ?? 0)); - $totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens))); - $estimatedCostUsd = (($inputTokens * (float)$this->aiPolicy['input_usd_per_1m']) - + ($outputTokens * (float)$this->aiPolicy['output_usd_per_1m'])) / 1000000; - - $this->aiUsage['requests']++; - $this->aiUsage['input_tokens'] += $inputTokens; - $this->aiUsage['output_tokens'] += $outputTokens; - $this->aiUsage['total_tokens'] += $totalTokens; - $this->aiUsage['estimated_cost_usd'] = round($this->aiUsage['estimated_cost_usd'] + $estimatedCostUsd, 6); - - try { - $usageService = new module_usage_service(); - $usageService->recordUsage('openai', 'api_calls', 1, [ - 'run_id' => $this->runId, - 'timeline' => $this->aiPolicy['timeline'], - ]); - if ($totalTokens > 0) { - $usageService->recordUsage('openai', 'total_tokens', $totalTokens, [ - 'run_id' => $this->runId, - 'timeline' => $this->aiPolicy['timeline'], - ]); - } - } catch (\Throwable) { - // Usage metrics are observability controls and must not break automation flow. - } - - $maxCost = $this->aiPolicy['max_cost_usd']; - if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) { - $this->aiUsage['budget_exhausted'] = true; - $this->aiUsage['stop_reason'] = 'ai_cost_limit_reached'; - } - } - - private function isOpenAiEnabled(): bool - { - try { - $xlvask = new xlvask(); - if (!$xlvask->config->minimax_integration_enabled->isTrue()) { - return false; - } - - $openai = new minimax(); - return $openai->config->enabled->isTrue(); - } catch (Exception) { - return false; - } - } -} diff --git a/services/nginx/app/classes/xlvask_autopilot_service.php b/services/nginx/app/classes/xlvask_autopilot_service.php deleted file mode 100644 index dbe2a680..00000000 --- a/services/nginx/app/classes/xlvask_autopilot_service.php +++ /dev/null @@ -1,1287 +0,0 @@ - ['import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true], - 'dry_run' => ['import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true], - 'replay' => ['import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true], - default => throw new Exception('Invalid XL Vask autopilot mode.'), - }; - } - - public static function normalizeHallScope(array $hallIds): array - { - $normalized = array_values(array_unique(array_filter(array_map( - static fn(mixed $id): string => trim((string)$id), - $hallIds - ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191))); - sort($normalized, SORT_STRING); - return $normalized; - } - - public static function idempotencyKey(string $providedKey, ?int $actorId, string $nonce): string - { - $material = trim($providedKey) !== '' ? trim($providedKey) : $nonce; - return hash('sha256', self::POLICY_VERSION . ':' . $material . ':' . (string)$actorId); - } - - public static function requestFingerprint(array $request): string - { - return hash('sha256', xlvask_automation_service::stableJsonForAutomation($request)); - } - - public static function previewSnapshotMatches(int $expectedVersion, string $sourceHash, array $current): bool - { - return (int)($current['expected_version'] ?? 0) === $expectedVersion - && $sourceHash !== '' - && hash_equals($sourceHash, (string)($current['source_hash'] ?? '')); - } - - public static function calibrationEvidenceMatchesIdentity(array $evidence, array $identity): bool - { - return (string)($identity['policy_version'] ?? '') !== '' - && (string)($identity['identity_hash'] ?? '') !== '' - && (string)($identity['model'] ?? '') !== '' - && hash_equals((string)$identity['policy_version'], (string)($evidence['policy_version'] ?? '')) - && hash_equals((string)($identity['identity_hash'] ?? ''), (string)($evidence['planner_identity_hash'] ?? '')) - && hash_equals((string)($identity['model'] ?? ''), (string)($evidence['model'] ?? '')); - } - - public static function scheduledExecutionAllowed(array $migrationStatus, array $capabilities): bool - { - return (bool)($migrationStatus['ready'] ?? false) - && in_array('execute', (array)($capabilities['allowed_modes'] ?? []), true); - } - - public function createRun(array $input, ?int $actorId = null, array $allowedHallIds = []): array - { - xlvask_usage_logs_schema_bootstrap::ensureTables(); - global $db; - - $mode = strtolower(trim((string)($input['mode'] ?? ''))); - if ($mode === '') { - throw new Exception('An explicit XL Vask autopilot mode is required.'); - } - self::modeCapabilities($mode); - $dateFrom = $this->normalizeDate($input['dateFrom'] ?? null, 'dateFrom'); - $dateTo = $this->normalizeDate($input['dateTo'] ?? null, 'dateTo'); - if ($dateFrom !== null && $dateTo !== null && $dateFrom > $dateTo) { - throw new Exception('dateFrom must not be after dateTo.'); - } - $ids = array_values(array_unique(array_filter( - array_map('intval', is_array($input['ids'] ?? null) ? $input['ids'] : []), - static fn(int $id): bool => $id > 0 - ))); - if (count($ids) > 500) { - throw new Exception('At most 500 XL Vask usage logs can be included in one run.'); - } - sort($ids, SORT_NUMERIC); - $requestedLimit = max(1, min(500, (int)($input['limit'] ?? 500))); - if ($dateFrom !== null && $dateTo !== null) { - $rangeDays = (int)floor((strtotime($dateTo) - strtotime($dateFrom)) / 86400) + 1; - $maxRange = $mode === 'replay' ? 366 : 90; - if ($rangeDays > $maxRange) { - throw new Exception("XL Vask {$mode} range cannot exceed {$maxRange} days."); - } - } - $forceRefetch = filter_var($input['forceRefetch'] ?? false, FILTER_VALIDATE_BOOL); - $aiTimeline = $this->normalizeAiTimeline($input['aiTimeline'] ?? null); - $aiBatchSize = $this->normalizeAiBatchSize($input['aiBatchSize'] ?? null, $aiTimeline); - $aiMaxCostUsd = $this->normalizeAiMaxCostUsd($input['aiMaxCostUsd'] ?? null); - $aiInputUsdPer1m = $this->normalizeAiRate($input['aiInputUsdPer1mUsd'] ?? null, 0.5); - $aiOutputUsdPer1m = $this->normalizeAiRate($input['aiOutputUsdPer1mUsd'] ?? null, 2.0); - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - if ($mode === 'execute') { - $capabilities = (new xlvask_automation_policy_service())->capabilitiesReadOnly( - $dateFrom, - $dateTo, - $allowedHallIds - ); - if (!in_array('execute', (array)($capabilities['allowed_modes'] ?? []), true)) { - throw new Exception( - 'XL Vask execute mode is blocked by server readiness: ' . - implode(', ', (array)($capabilities['blocked_reasons'] ?? ['policy_not_active'])) - ); - } - } - // An explicit retry key is stable; otherwise every requested rerun is a new durable run. - $providedKey = trim((string)($input['idempotency_key'] ?? '')); - $key = self::idempotencyKey($providedKey, $actorId, $this->uuidV4()); - $requestHash = self::requestFingerprint([ - 'mode' => $mode, - 'date_from' => $dateFrom, - 'date_to' => $dateTo, - 'ids' => $ids, - 'limit' => $requestedLimit, - 'force_refetch' => $forceRefetch, - 'scope_hall_ids' => $allowedHallIds, - 'ai_timeline' => $aiTimeline, - 'ai_batch_size' => $aiBatchSize, - 'ai_max_cost_usd' => $aiMaxCostUsd, - 'ai_input_usd_per_1m' => $aiInputUsdPer1m, - 'ai_output_usd_per_1m' => $aiOutputUsdPer1m, - ]); - $idsJson = $db->escape_string(json_encode($ids, JSON_UNESCAPED_SLASHES) ?: '[]'); - $scopeJson = $db->escape_string(json_encode($allowedHallIds, JSON_UNESCAPED_SLASHES) ?: '[]'); - $dateFromSql = $dateFrom === null ? 'NULL' : "'" . $db->escape_string($dateFrom) . "'"; - $dateToSql = $dateTo === null ? 'NULL' : "'" . $db->escape_string($dateTo) . "'"; - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - $keySql = $db->escape_string($key); - $modeSql = $db->escape_string($mode); - $aiTimelineSql = $db->escape_string($aiTimeline); - $aiMaxCostSql = $aiMaxCostUsd === null ? 'NULL' : (string)round($aiMaxCostUsd, 4); - $aiInputRateSql = (string)round($aiInputUsdPer1m, 4); - $aiOutputRateSql = (string)round($aiOutputUsdPer1m, 4); - - if ($db->query( - "INSERT INTO xlvask_autopilot_runs - (idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json, - requested_limit, scope_hall_ids_json, ai_timeline, ai_batch_size, ai_max_cost_usd, - ai_input_usd_per_1m_usd, ai_output_usd_per_1m_usd, created_by) - VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ", - '{$idsJson}', {$requestedLimit}, '{$scopeJson}', '{$aiTimelineSql}', {$aiBatchSize}, {$aiMaxCostSql}, - {$aiInputRateSql}, {$aiOutputRateSql}, {$actorSql}) - ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" - ) === false) { - throw new Exception('The XL Vask autopilot run could not be queued atomically.'); - } - $runId = (int)$db->insert_id(); - $existing = $db->fetch_assoc($db->query("SELECT request_hash FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1")); - if (!hash_equals($requestHash, (string)($existing['request_hash'] ?? ''))) { - throw new Exception('An active execute run exists or the idempotency key belongs to a different request.'); - } - - return $this->getRun($runId, $actorId, $allowedHallIds); - } - - public function processQueuedRuns(int $limit = 3): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $limit = max(1, min(10, $limit)); - $db->query( - "UPDATE xlvask_autopilot_runs - SET status = 'failed', phase = 'failed', error = 'XL Vask autopilot retry budget exhausted.', - lease_token = NULL, lease_expires_at = NULL, finished_at = NOW() - WHERE status IN ('queued', 'running') AND attempt_count >= max_attempts - AND (status = 'queued' OR lease_expires_at < NOW())" - ); - $rows = $db->fetch_all($db->query( - "SELECT id FROM xlvask_autopilot_runs - WHERE attempt_count < max_attempts AND ( - (status = 'queued' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())) - OR (status = 'running' AND lease_expires_at < NOW()) - ) - ORDER BY id ASC LIMIT {$limit}" - )); - $runs = []; - foreach ($rows as $row) { - $runs[] = $this->processRun((int)$row['id']); - } - return $runs; - } - - public function processRun(int $runId): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $connection = $db->conn(); - $connection->begin_transaction(); - try { - $result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} FOR UPDATE"); - if ($result === false || $result->num_rows < 1) { - throw new Exception('XL Vask autopilot run not found.'); - } - $run = $db->fetch_assoc($result); - $leaseExpired = $run['lease_expires_at'] !== null && strtotime((string)$run['lease_expires_at']) < time(); - if (in_array((string)$run['status'], ['completed', 'completed_with_warnings', 'failed'], true) - || ((string)$run['status'] === 'running' && !$leaseExpired)) { - $connection->commit(); - return $this->formatRun($run); - } - $leaseToken = $this->uuidV4(); - $db->query( - "UPDATE xlvask_autopilot_runs SET status = 'running', phase = 'importing', - lease_token = '{$leaseToken}', lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE), - attempt_count = attempt_count + 1, next_attempt_at = NULL, - started_at = COALESCE(started_at, NOW()) WHERE id = {$runId}" - ); - $run['attempt_count'] = (int)($run['attempt_count'] ?? 0) + 1; - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - - try { - $dateFrom = $run['date_from'] ?: null; - $dateTo = $run['date_to'] ?: null; - $ids = json_decode((string)($run['requested_ids_json'] ?? '[]'), true); - $ids = is_array($ids) ? array_values(array_filter(array_map('intval', $ids))) : []; - $scopeHallIds = json_decode((string)($run['scope_hall_ids_json'] ?? '[]'), true); - $scopeHallIds = is_array($scopeHallIds) ? $this->normalizeHallIds($scopeHallIds) : []; - $importSummary = ['fetched' => 0, 'upstream_fetched' => 0, 'new' => 0, 'updated' => 0, 'unchanged' => 0, 'invalid' => 0, 'errors' => []]; - $capabilities = self::modeCapabilities((string)$run['mode']); - $isReplay = !$capabilities['persist_plans']; - if ($capabilities['import'] && ($ids === [] || (int)$run['force_refetch'] === 1)) { - $importSummary = (new xlvask_usage_logs_o())->importUsageLogsWithSummary($dateFrom, $dateTo, $scopeHallIds); - } - - $leaseTokenSql = $db->escape_string($leaseToken); - $renewLease = static function () use ($db, $runId, $leaseTokenSql): void { - if ($db->query( - "UPDATE xlvask_autopilot_runs - SET lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE) - WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}'" - ) === false) { - throw new Exception('XL Vask autopilot lease ownership was lost during processing.'); - } - if ($db->conn()->affected_rows !== 1) { - $owned = $db->query( - "SELECT id FROM xlvask_autopilot_runs - WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}' LIMIT 1" - ); - if ($owned === false || $owned->num_rows !== 1) { - throw new Exception('XL Vask autopilot lease ownership was lost during processing.'); - } - } - }; - $db->query("UPDATE xlvask_autopilot_runs SET phase = 'evaluating' WHERE id = {$runId}"); - $renewLease(); - $automation = new xlvask_automation_service(); - $automation->setRunContext($runId); - $automation->setAiExecutionPolicy([ - 'timeline' => (string)($run['ai_timeline'] ?? 'standard'), - 'batch_size' => (int)($run['ai_batch_size'] ?? 150), - 'max_cost_usd' => isset($run['ai_max_cost_usd']) && $run['ai_max_cost_usd'] !== null - ? (float)$run['ai_max_cost_usd'] : null, - 'input_usd_per_1m' => (float)($run['ai_input_usd_per_1m_usd'] ?? 0.5), - 'output_usd_per_1m' => (float)($run['ai_output_usd_per_1m_usd'] ?? 2.0), - ]); - $allowExecute = $capabilities['execute_actions']; - $results = $automation->runPending( - $dateFrom, - $dateTo, - $ids, - max(1, min(500, (int)($run['requested_limit'] ?? 500))), - isset($run['created_by']) ? (int)$run['created_by'] : null, - $allowExecute, - $runId, - $scopeHallIds, - $isReplay, - $renewLease - ); - $this->persistRunItems($runId, $results['results'] ?? []); - $summary = $this->getSummary($dateFrom, $dateTo, $scopeHallIds); - $summary['import'] = $importSummary; - $summary['ai_usage'] = (array)($results['ai_usage'] ?? []); - $summary['ai_timeline'] = (string)($run['ai_timeline'] ?? 'standard'); - $circuitBreaker = $results['circuit_breaker'] ?? null; - $warning = $circuitBreaker === null ? null : 'Autopilot stopped early: ' . (string)$circuitBreaker; - $summary['circuit_breaker'] = $circuitBreaker; - $summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $processed = (int)($results['processed'] ?? 0); - $total = (int)($results['eligible_total'] ?? $results['selected'] ?? $processed); - if ($circuitBreaker === null && $processed < $total) { - $circuitBreaker = 'batch_limit_reached'; - $warning = 'Autopilot stopped early: batch_limit_reached'; - $summary['circuit_breaker'] = $circuitBreaker; - $summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - } - $status = $circuitBreaker === null ? 'completed' : 'completed_with_warnings'; - $phase = $circuitBreaker === null ? 'completed' : 'circuit_breaker'; - $warningSql = $warning === null ? 'NULL' : "'" . $db->escape_string($warning) . "'"; - $aiUsage = (array)($results['ai_usage'] ?? []); - $aiRequests = (int)($aiUsage['requests'] ?? 0); - $aiCacheHits = (int)($aiUsage['cache_hits'] ?? 0); - $aiInputTokens = (int)($aiUsage['input_tokens'] ?? 0); - $aiOutputTokens = (int)($aiUsage['output_tokens'] ?? 0); - $aiTotalTokens = (int)($aiUsage['total_tokens'] ?? 0); - $aiEstimatedCost = round((float)($aiUsage['estimated_cost_usd'] ?? 0), 6); - $aiBudgetExhausted = !empty($aiUsage['budget_exhausted']) ? 1 : 0; - $db->query( - "UPDATE xlvask_autopilot_runs SET status = '{$status}', phase = '{$phase}', processed = {$processed}, - total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql}, - ai_requests = {$aiRequests}, ai_cache_hits = {$aiCacheHits}, - ai_input_tokens = {$aiInputTokens}, ai_output_tokens = {$aiOutputTokens}, - ai_total_tokens = {$aiTotalTokens}, ai_estimated_cost_usd = {$aiEstimatedCost}, - ai_budget_exhausted = {$aiBudgetExhausted}, finished_at = NOW() - WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" - ); - if ($db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask autopilot lease ownership was lost before completion.'); - } - if (!$isReplay && $dateFrom !== null && $dateTo !== null) { - (new redis())->clear_invoice_period_automatic_flags($dateFrom, $dateTo); - } - } catch (Throwable $throwable) { - $message = $db->escape_string('XL Vask autopilot run failed. Review the server-side audit trail.'); - $attempt = (int)($run['attempt_count'] ?? 1); - $maxAttempts = max(1, (int)($run['max_attempts'] ?? 3)); - if ($attempt < $maxAttempts) { - $backoffMinutes = min(15, 2 ** max(0, $attempt - 1)); - $db->query( - "UPDATE xlvask_autopilot_runs SET status = 'queued', phase = 'retry_wait', error = '{$message}', - warning = 'Transient failure; retry scheduled.', lease_token = NULL, lease_expires_at = NULL, - next_attempt_at = DATE_ADD(NOW(), INTERVAL {$backoffMinutes} MINUTE) - WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" - ); - } else { - $db->query( - "UPDATE xlvask_autopilot_runs SET status = 'failed', phase = 'failed', error = '{$message}', - lease_token = NULL, lease_expires_at = NULL, finished_at = NOW() - WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" - ); - } - } - - return $this->getRun($runId, isset($run['created_by']) ? (int)$run['created_by'] : null, $scopeHallIds ?? []); - } - - public function getRun(int $runId, ?int $actorId = null, array $allowedHallIds = []): array - { - global $db; - $result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1"); - if ($result === false || $result->num_rows < 1) { - throw new Exception('XL Vask autopilot run not found.'); - } - $row = $db->fetch_assoc($result); - $runScope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true); - $runScope = is_array($runScope) ? $this->normalizeHallIds($runScope) : []; - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($actorId !== null && $allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - if ($actorId !== null && (int)($row['created_by'] ?? 0) !== $actorId) { - throw new Exception('XL Vask autopilot run belongs to another user.'); - } - if ($allowedHallIds !== [] && array_diff($runScope, $allowedHallIds) !== []) { - throw new Exception('XL Vask autopilot run is outside the current hall scope.'); - } - return $this->formatRun($row); - } - - public function getSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array - { - global $db; - $states = [ - 'total', 'new', 'updated', 'unchanged', 'invalid', 'already_linked', 'auto_linked', - 'auto_created', 'needs_review', 'blocked', 'ignored', 'failed', 'certain', 'uncertain', 'none', - ]; - $summary = array_fill_keys($states, 0); - $startExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"; - $where = ['1=1']; - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - $where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; - if ($dateFrom !== null && strtotime($dateFrom) !== false) { - $where[] = "{$startExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; - } - if ($dateTo !== null && strtotime($dateTo) !== false) { - $where[] = "{$startExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; - } - - try { - $row = $db->fetch_assoc($db->query( - "SELECT COUNT(*) total, - SUM(import_state = 'new') `new`, SUM(import_state = 'updated') updated, - SUM(import_state = 'unchanged') unchanged, SUM(import_state = 'invalid') invalid, - SUM(resolution_state = 'already_linked') already_linked, - SUM(resolution_state = 'auto_linked') auto_linked, - SUM(resolution_state = 'auto_created') auto_created, - SUM(resolution_state = 'needs_review') needs_review, - SUM(resolution_state = 'blocked') blocked, - SUM(resolution_state = 'ignored') ignored, - SUM(resolution_state = 'failed') failed, - SUM(certainty = 'certain') certain, SUM(certainty = 'uncertain') uncertain, - SUM(certainty = 'none') none - FROM xlvask_usage_logs WHERE " . implode(' AND ', $where) - )); - foreach ($summary as $key => $_) { - $summary[$key] = (int)($row[$key] ?? 0); - } - } catch (Throwable) { - $row = $db->fetch_assoc($db->query( - 'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) - )); - $summary['total'] = (int)($row['total'] ?? 0); - $summary['needs_review'] = $summary['total']; - } - - return $summary; - } - - public function activationReadiness(): array - { - global $db; - $active = []; - try { - $result = $db->query( - "SELECT id, policy_version, segment_key, precision_value, wilson_lower_bound, - holdout_examples, segment_examples, contradictions, calibrated_probability, - artifact_hash, backtest_json, activated_at - FROM xlvask_automation_calibrations - WHERE active = 1 AND invalidated_at IS NULL ORDER BY segment_key" - ); - if ($result !== false) { - $verified = []; - foreach ($db->fetch_all($result) as $row) { - $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); - if (!is_array($artifact) - || ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' - || ($artifact['policy_version'] ?? null) !== (string)$row['policy_version'] - || ($artifact['segment_key'] ?? null) !== (string)$row['segment_key'] - || !hash_equals( - (string)($artifact['automation_identity_hash'] ?? ''), - (string)xlvask_automation_service::automationIdentityForAutomation()['identity_hash'] - ) - || !hash_equals( - (string)($artifact['resolved_model'] ?? ''), - (string)xlvask_automation_service::automationIdentityForAutomation()['model'] - ) - || !hash_equals( - (string)($row['artifact_hash'] ?? ''), - hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact)) - )) { - continue; - } - $verified[] = [ - ...$artifact, - 'id' => (int)$row['id'], - 'active' => true, - 'artifact_hash' => (string)$row['artifact_hash'], - 'activated_at' => $row['activated_at'] ?? null, - ]; - } - $active = array_values(array_filter( - $verified, - static fn(array $artifact): bool => xlvask_automation_service::classifyCertaintyForAutomation($artifact) === 'certain' - )); - } - } catch (Throwable) { - // Readiness GET is intentionally read-only and fails closed when schema is not ready. - } - $policyReadiness = (new xlvask_automation_policy_service())->readinessReadOnly(); - return [ - ...$policyReadiness, - 'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(), - 'active_calibrations' => $active, - 'automatic_actions_ready' => (bool)($policyReadiness['ready'] ?? false), - 'wash_id_activation_phrase' => 'ACTIVATE-WASH-ID-UNIQUENESS', - ]; - } - - public function adjudicateCalibrationLabel( - int $suggestionId, - string $outcome, - ?int $actorId, - array $allowedHallIds = [] - ): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - if ($suggestionId < 1 || !in_array($outcome, ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'], true) || $actorId === null) { - throw new Exception('Invalid XL Vask calibration adjudication.'); - } - $allowedHallIds = self::normalizeHallScope($allowedHallIds); - if ($allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for calibration adjudication.'); - } - $hallSql = implode(',', array_map( - static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'", - $allowedHallIds - )); - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $policySql = $db->escape_string((string)$identity['policy_version']); - $identitySql = $db->escape_string((string)$identity['identity_hash']); - $modelSql = $db->escape_string((string)$identity['model']); - $calibrationOutcome = $outcome === 'correct' ? 'correct' : 'incorrect'; - $outcomeSql = $db->escape_string($calibrationOutcome); - $connection = $db->conn(); - $connection->begin_transaction(); - try { - $suggestion = $db->query( - "SELECT s.id, s.policy_version, s.source, s.action - FROM xlvask_automation_suggestions s - INNER JOIN xlvask_usage_logs u ON u.id = s.usage_log_id - LEFT JOIN xlvask_automation_action_events ae ON ae.suggestion_id = s.id - WHERE s.id = {$suggestionId} AND u.HallId IN ({$hallSql}) - AND ( - ae.id IS NOT NULL - OR ( - s.status = 'suggested' AND s.source = 'openai' - AND s.action IN ('attach_order', 'create_order') - AND s.policy_version = '{$policySql}' - AND BINARY s.planner_identity_hash = BINARY '{$identitySql}' - AND BINARY s.model = BINARY '{$modelSql}' - AND s.expected_version = u.expected_version AND s.input_hash = u.source_hash - AND u.resolution_state = 'needs_review' AND u.import_state <> 'invalid' - AND u.ignored_at IS NULL AND u.FinishStatus = 1 - AND NOT EXISTS ( - SELECT 1 FROM xlvask_automation_suggestions newer - WHERE newer.usage_log_id = s.usage_log_id AND newer.id > s.id - ) - ) - ) - LIMIT 1 FOR UPDATE" - ); - if ($suggestion === false || $suggestion->num_rows < 1) { - throw new Exception('XL Vask suggestion not found for calibration adjudication.'); - } - $automaticReview = (new xlvask_automation_policy_service())->reviewAutomaticActionBySuggestion( - $suggestionId, - $outcome, - (int)$actorId, - true - ); - $latestLabelResult = $db->query( - "SELECT adjudication_outcome FROM xlvask_automation_calibration_label_events - WHERE suggestion_id = {$suggestionId} ORDER BY id DESC LIMIT 1 FOR UPDATE" - ); - $latestLabel = $latestLabelResult !== false && $latestLabelResult->num_rows > 0 - ? $db->fetch_assoc($latestLabelResult) - : null; - $labelRetry = $latestLabel !== null - && xlvask_automation_policy_service::adjudicationRetryMatches( - (string)($latestLabel['adjudication_outcome'] ?? ''), - $outcome - ); - if ($latestLabel !== null && !$labelRetry) { - throw new Exception('The XL Vask suggestion outcome was already adjudicated differently.'); - } - if (!(bool)($automaticReview['idempotent'] ?? false) - && !$labelRetry - && $db->query( - "INSERT INTO xlvask_automation_calibration_label_events - (suggestion_id, outcome, adjudication_outcome, adjudicated_by, adjudicated_at) - VALUES ({$suggestionId}, '{$outcomeSql}', '" . $db->escape_string($outcome) . "', {$actorId}, NOW())" - ) === false) { - throw new Exception('XL Vask calibration adjudication could not be stored.'); - } - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - return [ - 'suggestion_id' => $suggestionId, - 'outcome' => $outcome, - 'calibration_outcome' => $calibrationOutcome, - 'adjudicated' => true, - 'action_halted' => (bool)($automaticReview['action_halted'] ?? false), - 'affected_action' => $automaticReview['affected_action'] ?? null, - 'automatic_action_review' => $automaticReview, - 'idempotent' => (bool)($automaticReview['idempotent'] ?? false) || $labelRetry, - ]; - } - - /** Generate an inactive, PII-free artifact from exact admin-adjudicated suggestion labels. */ - public function generateCalibrationArtifact(string $segmentKey, ?int $actorId): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - if (!preg_match('/^(deterministic|fuzzy|history|openai):(attach_order|create_order)$/', $segmentKey)) { - throw new Exception('Invalid XL Vask calibration segment.'); - } - [$source, $action] = explode(':', $segmentKey, 2); - $sourceSql = $db->escape_string($source); - $actionSql = $db->escape_string($action); - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $identitySql = $db->escape_string((string)$identity['identity_hash']); - $modelSql = $db->escape_string((string)$identity['model']); - $labels = $db->fetch_all($db->query( - "SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at, - s.policy_version, s.planner_identity_hash, s.model, - s.source, s.action, s.created_at AS suggestion_created_at - FROM xlvask_automation_calibration_label_events l - LEFT JOIN xlvask_automation_calibration_label_events newer - ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id - INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id - WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}' - AND s.policy_version = '" . self::POLICY_VERSION . "' - AND BINARY s.planner_identity_hash = BINARY '{$identitySql}' - AND BINARY s.model = BINARY '{$modelSql}' - AND newer.id IS NULL - ORDER BY s.created_at ASC, s.id ASC, l.id ASC" - )); - $labels = array_values(array_filter( - $labels, - static fn(array $label): bool => self::calibrationEvidenceMatchesIdentity($label, $identity) - )); - $overallRow = $db->fetch_assoc($db->query( - "SELECT COUNT(*) AS total - FROM xlvask_automation_calibration_label_events l - LEFT JOIN xlvask_automation_calibration_label_events newer - ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id - INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id - WHERE s.policy_version = '" . self::POLICY_VERSION . "' - AND BINARY s.planner_identity_hash = BINARY '{$identitySql}' - AND BINARY s.model = BINARY '{$modelSql}' - AND newer.id IS NULL" - )); - $overallExamples = (int)($overallRow['total'] ?? 0); - $total = count($labels); - $trainingExamples = (int)floor($total * 0.8); - $holdout = array_slice($labels, $trainingExamples); - $holdoutExamples = count($holdout); - $accepted = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'correct')); - $denied = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'incorrect')); - $contradictions = count(array_filter($labels, static fn(array $label): bool => $label['outcome'] === 'incorrect')); - $precision = $holdoutExamples > 0 ? $accepted / $holdoutExamples : 0.0; - $wilson = $this->wilsonLowerBound($accepted, $holdoutExamples); - $snapshot = array_map(static fn(array $label): array => [ - 'id' => (int)$label['id'], - 'suggestion_id' => (int)$label['suggestion_id'], - 'outcome' => (string)$label['outcome'], - 'adjudicated_at' => (string)$label['adjudicated_at'], - 'suggestion_created_at' => (string)$label['suggestion_created_at'], - 'policy_version' => (string)$label['policy_version'], - 'planner_identity_hash' => (string)$label['planner_identity_hash'], - 'resolved_model' => (string)$label['model'], - 'source' => (string)$label['source'], - 'action' => (string)$label['action'], - ], $labels); - $artifact = [ - 'policy_version' => self::POLICY_VERSION, - 'automation_identity' => $identity, - 'automation_identity_hash' => (string)$identity['identity_hash'], - 'resolved_model' => (string)$identity['model'], - 'segment_key' => $segmentKey, - 'safety_epoch' => $this->calibrationSafetyEpoch($segmentKey), - 'split_rule' => 'chronological_80_20_by_suggestion_created_at_and_id', - 'training_examples' => $trainingExamples, - 'holdout_examples' => $holdoutExamples, - 'overall_examples' => $overallExamples, - 'segment_examples' => $total, - 'correct_holdout_examples' => $accepted, - 'incorrect_holdout_examples' => $denied, - 'contradictions' => $contradictions, - 'precision_value' => round($precision, 6), - 'wilson_lower_bound' => round($wilson, 6), - 'calibrated_probability' => round($precision, 6), - 'label_snapshot' => $snapshot, - 'label_snapshot_hash' => hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot)), - ]; - $artifactHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact)); - $artifactJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($artifact)); - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - $inserted = $db->query( - "INSERT INTO xlvask_automation_calibrations - (policy_version, segment_key, automation_identity_hash, precision_value, wilson_lower_bound, holdout_examples, - segment_examples, contradictions, calibrated_probability, artifact_hash, active, backtest_json, created_by) - VALUES ('" . self::POLICY_VERSION . "', '" . $db->escape_string($segmentKey) . "', '" . - $db->escape_string((string)$artifact['automation_identity_hash']) . "', " . round($precision, 6) . ", - " . round($wilson, 6) . ", {$holdoutExamples}, {$total}, {$contradictions}, " . round($precision, 6) . ", - '{$artifactHash}', 0, '{$artifactJson}', {$actorSql}) - ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" - ); - if ($inserted === false) { - throw new Exception('The XL Vask calibration artifact could not be persisted.'); - } - $id = (int)$db->insert_id(); - if ($id < 1) { - throw new Exception('The XL Vask calibration artifact identifier is unavailable.'); - } - $qualifies = xlvask_automation_service::classifyCertaintyForAutomation([ - ...$artifact, - 'active' => true, - ]) === 'certain'; - return [ - 'id' => $id, - ...$artifact, - 'artifact_hash' => $artifactHash, - 'active' => false, - 'qualifies_for_activation' => $qualifies, - 'activation_phrase' => 'ACTIVATE-CALIBRATION-' . $id, - ]; - } - - public function activateCalibration(int $id, string $artifactHash, string $confirmationText, ?int $actorId): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - if ($id < 1 || !preg_match('/^[0-9a-f]{64}$/', $artifactHash) - || !hash_equals('ACTIVATE-CALIBRATION-' . $id, trim($confirmationText))) { - throw new Exception('Invalid XL Vask calibration activation confirmation.'); - } - $connection = $db->conn(); - $connection->begin_transaction(); - try { - $result = $db->query("SELECT * FROM xlvask_automation_calibrations WHERE id = {$id} FOR UPDATE"); - $row = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; - if ($row === null || !hash_equals((string)$row['artifact_hash'], $artifactHash)) { - throw new Exception('XL Vask calibration artifact not found or changed.'); - } - if (!empty($row['invalidated_at'])) { - throw new Exception('XL Vask calibration artifact was invalidated by an action safety latch.'); - } - $backtest = json_decode((string)($row['backtest_json'] ?? ''), true); - if (!is_array($backtest) - || ($backtest['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' - || ($backtest['policy_version'] ?? null) !== (string)$row['policy_version'] - || ($backtest['segment_key'] ?? null) !== (string)$row['segment_key'] - || (int)($backtest['safety_epoch'] ?? -1) !== $this->calibrationSafetyEpoch((string)$row['segment_key']) - || !hash_equals( - (string)($backtest['automation_identity_hash'] ?? ''), - (string)xlvask_automation_service::automationIdentityForAutomation()['identity_hash'] - ) - || !hash_equals( - (string)($backtest['resolved_model'] ?? ''), - (string)xlvask_automation_service::automationIdentityForAutomation()['model'] - ) - || !hash_equals($artifactHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($backtest)))) { - throw new Exception('XL Vask calibration artifact payload failed integrity verification.'); - } - $currentSnapshot = $this->currentCalibrationLabelSnapshot((string)$row['segment_key']); - if (!hash_equals( - (string)($backtest['label_snapshot_hash'] ?? ''), - hash('sha256', xlvask_automation_service::stableJsonForAutomation($currentSnapshot)) - )) { - throw new Exception('XL Vask calibration labels changed after this artifact was generated.'); - } - if (xlvask_automation_service::classifyCertaintyForAutomation([...$backtest, 'active' => true]) !== 'certain') { - throw new Exception('XL Vask calibration artifact does not meet the activation thresholds.'); - } - $policy = $db->escape_string((string)$row['policy_version']); - $segment = $db->escape_string((string)$row['segment_key']); - $db->query( - "UPDATE xlvask_automation_calibrations SET active = 0 - WHERE policy_version = '{$policy}' AND segment_key = '{$segment}' AND active = 1" - ); - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - if ($db->query( - "UPDATE xlvask_automation_calibrations - SET active = 1, activated_by = {$actorSql}, activated_at = NOW() WHERE id = {$id} AND active = 0" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('XL Vask calibration artifact could not be activated atomically.'); - } - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - return ['id' => $id, 'active' => true, 'segment_key' => (string)$row['segment_key']]; - } - - private function currentCalibrationLabelSnapshot(string $segmentKey): array - { - global $db; - if (!preg_match('/^(deterministic|fuzzy|history|openai):(attach_order|create_order)$/', $segmentKey)) { - return []; - } - [$source, $action] = explode(':', $segmentKey, 2); - $sourceSql = $db->escape_string($source); - $actionSql = $db->escape_string($action); - $identity = xlvask_automation_service::automationIdentityForAutomation(); - $identitySql = $db->escape_string((string)$identity['identity_hash']); - $modelSql = $db->escape_string((string)$identity['model']); - $rows = $db->fetch_all($db->query( - "SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at, - s.policy_version, s.planner_identity_hash, s.model, - s.source, s.action, s.created_at AS suggestion_created_at - FROM xlvask_automation_calibration_label_events l - LEFT JOIN xlvask_automation_calibration_label_events newer - ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id - INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id - WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}' - AND s.policy_version = '" . self::POLICY_VERSION . "' - AND BINARY s.planner_identity_hash = BINARY '{$identitySql}' - AND BINARY s.model = BINARY '{$modelSql}' - AND newer.id IS NULL - ORDER BY s.created_at ASC, s.id ASC, l.id ASC" - )); - $rows = array_values(array_filter( - $rows, - static fn(array $label): bool => self::calibrationEvidenceMatchesIdentity($label, $identity) - )); - return array_map(static fn(array $label): array => [ - 'id' => (int)$label['id'], - 'suggestion_id' => (int)$label['suggestion_id'], - 'outcome' => (string)$label['outcome'], - 'adjudicated_at' => (string)$label['adjudicated_at'], - 'suggestion_created_at' => (string)$label['suggestion_created_at'], - 'policy_version' => (string)$label['policy_version'], - 'planner_identity_hash' => (string)$label['planner_identity_hash'], - 'resolved_model' => (string)$label['model'], - 'source' => (string)$label['source'], - 'action' => (string)$label['action'], - ], $rows); - } - - private function calibrationSafetyEpoch(string $segmentKey): int - { - global $db; - $segmentSql = $db->escape_string($segmentKey); - $row = $db->fetch_assoc($db->query( - "SELECT COALESCE(MAX(id), 0) safety_epoch FROM xlvask_automation_policy_events - WHERE event_type = 'action_latch_halted' - AND JSON_UNQUOTE(JSON_EXTRACT(details_json, '$.invalidated_calibration_segment')) = '{$segmentSql}'" - )); - return (int)($row['safety_epoch'] ?? 0); - } - - public function activateWashIdUniqueness(string $confirmationText): array - { - if (!hash_equals('ACTIVATE-WASH-ID-UNIQUENESS', trim($confirmationText))) { - throw new Exception('Invalid wash-id uniqueness activation confirmation.'); - } - $ready = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration(); - if (!$ready) { - throw new Exception('Wash-id uniqueness activation is blocked by schema readiness or duplicate wash IDs.'); - } - return ['wash_id_uniqueness_ready' => true]; - } - - public function createDecisionPreview(array $input, ?int $actorId = null, array $allowedHallIds = []): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $ids = array_values(array_unique(array_filter(array_map( - 'intval', - is_array($input['usage_log_ids'] ?? null) ? $input['usage_log_ids'] : [] - )))); - if ($ids === []) { - throw new Exception('At least one XL Vask usage log is required.'); - } - if (count($ids) > 100) { - throw new Exception('At most 100 XL Vask usage logs can be reviewed at once.'); - } - $action = strtolower(trim((string)($input['action'] ?? ''))); - if (!in_array($action, ['accept', 'deny', 'ignore', 'attach_order', 'create_order'], true)) { - throw new Exception('Invalid XL Vask review action.'); - } - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - $result = $db->query( - 'SELECT id, WashId, import_state, resolution_state, certainty, planned_action, expected_version, source_hash ' - . 'FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ') AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') ORDER BY id' - ); - $rows = $db->fetch_all($result); - if (count($rows) !== count($ids)) { - throw new Exception('One or more XL Vask usage logs were not found.'); - } - $items = []; - foreach ($rows as $row) { - $usageId = (int)$row['id']; - if ((string)($row['import_state'] ?? '') === 'invalid') { - throw new Exception("XL Vask usage log {$usageId} has invalid source data."); - } - $suggestion = null; - if ($action !== 'ignore') { - $suggestionResult = $db->query( - "SELECT id, usage_log_id, action, matched_order_id FROM xlvask_automation_suggestions - WHERE usage_log_id = {$usageId} AND status = 'suggested' ORDER BY id DESC LIMIT 1" - ); - $suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0 - ? $db->fetch_assoc($suggestionResult) : null; - if ($suggestion === null) { - // Allow the operator to act on a wash that the AI autopilot has - // not yet scored. The manual suggestion is a deterministic - // proposal derived from the wash log itself. - $forceManual = (bool)($input['force_manual'] ?? false); - if ($forceManual) { - // Map the high-level review action to the action stored on - // the suggestion row. "ignore" is a no-op suggestion; the - // remaining actions become create_order / attach_order. - $manualSuggestionAction = match ($action) { - 'attach_order' => 'attach_order', - 'create_order', 'accept' => 'create_order', - 'deny', 'ignore' => 'ignore', - default => 'ignore', - }; - $automationService = new xlvask_automation_service(); - $suggestionId = $automationService->createManualSuggestion( - $usageId, - $manualSuggestionAction, - $actorId, - $this->normalizeHallIds($allowedHallIds) - ); - $suggestion = [ - 'id' => $suggestionId, - 'usage_log_id' => $usageId, - 'action' => $manualSuggestionAction, - 'matched_order_id' => null, - 'source' => 'manual', - ]; - } else { - throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion. Pass force_manual to act on it without an autopilot suggestion."); - } - } - if (isset($input['suggestion_id']) && count($rows) === 1 && (int)$suggestion['id'] !== (int)$input['suggestion_id']) { - throw new Exception('The selected XL Vask suggestion is stale.'); - } - if ($action === 'attach_order' && (int)($input['order_id'] ?? 0) > 0) { - $candidate = (new xlvask_automation_service())->validateManualCandidate( - $usageId, - (int)$input['order_id'] - ); - $suggestion['matched_order_id'] = (int)$candidate['id']; - } - } - $items[] = [ - 'usage_log_id' => $usageId, - 'suggestion_id' => $suggestion === null ? null : (int)$suggestion['id'], - 'candidate_order_id' => $suggestion === null || $suggestion['matched_order_id'] === null - ? null : (int)$suggestion['matched_order_id'], - 'suggested_action' => $suggestion['action'] ?? null, - 'before' => [ - 'resolution_state' => (string)$row['resolution_state'], - 'certainty' => (string)$row['certainty'], - 'planned_action' => (string)$row['planned_action'], - 'expected_version' => (int)$row['expected_version'], - 'source_hash' => (string)$row['source_hash'], - ], - 'after' => ['action' => $action], - 'warnings' => [], - ]; - } - $requiresConfirmation = count($items) > 1 || in_array($action, ['deny', 'ignore'], true); - $confirmationPhrase = $requiresConfirmation ? 'CONFIRM-' . strtoupper(bin2hex(random_bytes(4))) : null; - $payload = [ - 'action' => $action, - 'items' => $items, - 'reason' => mb_substr(trim((string)($input['reason'] ?? '')), 0, 1000), - 'confirmation_phrase' => $confirmationPhrase, - ]; - $selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($payload)); - $id = $this->uuidV4(); - $expiresAt = date('Y-m-d H:i:s', time() + self::PREVIEW_TTL_SECONDS); - $payloadJson = $db->escape_string(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - $db->query( - "INSERT INTO xlvask_automation_decision_previews - (id, selection_hash, action, payload_json, created_by, expires_at) - VALUES ('{$id}', '{$selectionHash}', '" . $db->escape_string($action) . "', '{$payloadJson}', {$actorSql}, '{$expiresAt}')" - ); - - return [ - 'id' => $id, - 'selection_hash' => $selectionHash, - 'expires_at' => $expiresAt, - 'action' => $action, - 'items' => $items, - 'requires_confirmation' => $requiresConfirmation, - 'confirmation_phrase' => $confirmationPhrase, - ]; - } - - public function applyDecision(array $input, ?int $actorId = null, array $allowedHallIds = []): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $previewId = trim((string)($input['preview_id'] ?? '')); - $selectionHash = trim((string)($input['selection_hash'] ?? '')); - if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $previewId) - || !preg_match('/^[0-9a-f]{64}$/i', $selectionHash)) { - throw new Exception('Invalid XL Vask decision preview identifiers.'); - } - $connection = $db->conn(); - $allowedHallIds = $this->normalizeHallIds($allowedHallIds); - if ($allowedHallIds === []) { - throw new Exception('No XL Vask hall scope is available for this user.'); - } - $connection->begin_transaction(); - try { - $result = $db->query( - "SELECT * FROM xlvask_automation_decision_previews WHERE id = '" . $db->escape_string($previewId) . "' FOR UPDATE" - ); - if ($result === false || $result->num_rows < 1) { - throw new Exception('XL Vask decision preview not found.'); - } - $preview = $db->fetch_assoc($result); - if ((int)($preview['created_by'] ?? 0) !== (int)($actorId ?? 0)) { - throw new Exception('XL Vask decision preview belongs to another user.'); - } - if (!hash_equals((string)$preview['selection_hash'], $selectionHash)) { - throw new Exception('XL Vask decision preview changed.'); - } - if ($preview['applied_at'] !== null || strtotime((string)$preview['expires_at']) < time()) { - throw new Exception('XL Vask decision preview has expired or was already applied.'); - } - $payload = json_decode((string)$preview['payload_json'], true); - if (!is_array($payload)) { - throw new Exception('XL Vask decision preview is invalid.'); - } - $requiresConfirmation = count($payload['items'] ?? []) > 1 || in_array($payload['action'] ?? '', ['deny', 'ignore'], true); - $confirmationPhrase = (string)($payload['confirmation_phrase'] ?? ''); - if ($requiresConfirmation && ($confirmationPhrase === '' - || !hash_equals($confirmationPhrase, trim((string)($input['confirmation_text'] ?? ''))))) { - throw new Exception('Confirmation text does not match the preview-issued phrase.'); - } - $automation = new xlvask_automation_service(); - $results = []; - foreach ($payload['items'] as $item) { - $usageId = (int)$item['usage_log_id']; - $expected = (int)$item['before']['expected_version']; - $locked = $db->query( - "SELECT expected_version, source_hash, HallId FROM xlvask_usage_logs WHERE id = {$usageId} FOR UPDATE" - ); - $current = $locked !== false && $locked->num_rows > 0 ? $db->fetch_assoc($locked) : null; - if ($current === null - || !in_array(trim((string)$current['HallId']), $allowedHallIds, true) - || !self::previewSnapshotMatches($expected, (string)$item['before']['source_hash'], $current)) { - throw new Exception("XL Vask usage log {$usageId} changed after preview."); - } - $action = (string)$payload['action']; - if ($action === 'ignore') { - $reason = $db->escape_string((string)$payload['reason']); - $actorSql = $actorId === null ? 'NULL' : (string)$actorId; - if ($db->query( - "UPDATE xlvask_usage_logs SET ignored_at = NOW(), ignored_by = {$actorSql}, ignored_reason = '{$reason}', - resolution_state = 'ignored', certainty = 'none', planned_action = 'none', expected_version = expected_version + 1 - WHERE id = {$usageId}" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask ignore decision could not be applied atomically.'); - } - if ($db->query( - "UPDATE xlvask_automation_suggestions SET status = 'superseded', updated_at = NOW() - WHERE usage_log_id = {$usageId} AND status = 'suggested'" - ) === false) { - throw new Exception('The ignored XL Vask suggestion could not be superseded atomically.'); - } - $results[] = ['usage_log_id' => $usageId, 'resolution_state' => 'ignored']; - } else { - $results[] = $automation->applyBoundDecisionWithinTransaction( - $usageId, - $action, - isset($item['suggestion_id']) ? (int)$item['suggestion_id'] : null, - isset($item['candidate_order_id']) && $item['candidate_order_id'] !== null - ? (int)$item['candidate_order_id'] : null, - $expected, - (string)$item['before']['source_hash'], - $actorId, - $payload['reason'] ?: null - ); - } - } - if ($db->query( - "UPDATE xlvask_automation_decision_previews SET applied_at = NOW() WHERE id = '" . $db->escape_string($previewId) . "'" - ) === false || $db->conn()->affected_rows !== 1) { - throw new Exception('The XL Vask decision preview could not be finalized atomically.'); - } - $connection->commit(); - } catch (Throwable $throwable) { - $connection->rollback(); - throw $throwable; - } - - return ['applied' => count($results), 'results' => $results, 'failed' => []]; - } - - private function persistRunItems(int $runId, array $results): void - { - global $db; - foreach ($results as $result) { - $usageId = (int)($result['usage_log_id'] ?? 0); - if ($usageId < 1) { - continue; - } - $rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageId} LIMIT 1"); - if ($rowResult === false || $rowResult->num_rows < 1) { - continue; - } - $row = $db->fetch_assoc($rowResult); - $durableResult = array_intersect_key($result, array_flip([ - 'usage_log_id', 'id', 'status', 'action', 'certainty', 'calibrated_probability', - 'source', 'matched_order_id', 'created_order_id', 'risk_flags', 'policy_version', - ])); - $resultJson = $db->escape_string(json_encode($durableResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - $db->query( - "INSERT INTO xlvask_autopilot_run_items - (run_id, usage_log_id, wash_id, import_state, resolution_state, certainty, planned_action, source_hash, expected_version, result_json) - VALUES ({$runId}, {$usageId}, '" . $db->escape_string((string)$row['WashId']) . "', - '" . $db->escape_string((string)($row['import_state'] ?? 'unchanged')) . "', - '" . $db->escape_string((string)($result['resolution_state'] ?? $row['resolution_state'] ?? 'needs_review')) . "', - '" . $db->escape_string((string)($result['certainty'] ?? $row['certainty'] ?? 'none')) . "', - '" . $db->escape_string((string)($result['planned_action'] ?? $row['planned_action'] ?? 'none')) . "', - '" . $db->escape_string((string)($row['source_hash'] ?? '')) . "', " . (int)($row['expected_version'] ?? 1) . ", '{$resultJson}') - ON DUPLICATE KEY UPDATE resolution_state = VALUES(resolution_state), certainty = VALUES(certainty), - planned_action = VALUES(planned_action), result_json = VALUES(result_json), updated_at = NOW()" - ); - } - } - - private function formatRun(array $row): array - { - $summary = json_decode((string)($row['summary_json'] ?? ''), true); - return [ - 'id' => (int)$row['id'], - 'status' => (string)$row['status'], - 'mode' => (string)$row['mode'], - 'date_from' => $row['date_from'] ?: null, - 'date_to' => $row['date_to'] ?: null, - 'phase' => (string)$row['phase'], - 'processed' => (int)$row['processed'], - 'total' => (int)$row['total'], - 'summary' => is_array($summary) ? $summary : null, - 'warning' => $row['warning'] ?: null, - 'error' => $row['error'] ?: null, - 'created_at' => $row['created_at'], - 'started_at' => $row['started_at'] ?: null, - 'finished_at' => $row['finished_at'] ?: null, - 'attempt_count' => (int)($row['attempt_count'] ?? 0), - 'max_attempts' => (int)($row['max_attempts'] ?? 3), - 'next_attempt_at' => $row['next_attempt_at'] ?: null, - 'ai' => [ - 'timeline' => (string)($row['ai_timeline'] ?? 'standard'), - 'batch_size' => (int)($row['ai_batch_size'] ?? 150), - 'max_cost_usd' => isset($row['ai_max_cost_usd']) && $row['ai_max_cost_usd'] !== null - ? (float)$row['ai_max_cost_usd'] : null, - 'input_usd_per_1m_usd' => (float)($row['ai_input_usd_per_1m_usd'] ?? 0.5), - 'output_usd_per_1m_usd' => (float)($row['ai_output_usd_per_1m_usd'] ?? 2.0), - 'requests' => (int)($row['ai_requests'] ?? 0), - 'cache_hits' => (int)($row['ai_cache_hits'] ?? 0), - 'input_tokens' => (int)($row['ai_input_tokens'] ?? 0), - 'output_tokens' => (int)($row['ai_output_tokens'] ?? 0), - 'total_tokens' => (int)($row['ai_total_tokens'] ?? 0), - 'estimated_cost_usd' => (float)($row['ai_estimated_cost_usd'] ?? 0.0), - 'budget_exhausted' => (bool)($row['ai_budget_exhausted'] ?? false), - ], - ]; - } - - public function pruneExpiredData(): array - { - global $db; - xlvask_usage_logs_schema_bootstrap::ensureTables(); - $deleted = []; - foreach ([ - 'previews' => 'DELETE FROM xlvask_automation_decision_previews WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)', - 'run_items' => 'DELETE FROM xlvask_autopilot_run_items WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)', - 'audit_events' => 'DELETE FROM xlvask_automation_audit WHERE created_at < DATE_SUB(NOW(), INTERVAL 730 DAY)', - ] as $key => $sql) { - if ($db->query($sql) === false) { - throw new Exception('XL Vask autopilot retention cleanup failed.'); - } - $deleted[$key] = (int)$db->conn()->affected_rows; - } - return $deleted; - } - - private function normalizeDate(mixed $value, string $name): ?string - { - $value = trim((string)($value ?? '')); - if ($value === '') { - return null; - } - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { - throw new Exception("Invalid {$name}."); - } - [$year, $month, $day] = array_map('intval', explode('-', $value)); - if (!checkdate($month, $day, $year)) { - throw new Exception("Invalid {$name}."); - } - return $value; - } - - private function normalizeAiTimeline(mixed $value): string - { - $timeline = strtolower(trim((string)($value ?? ''))); - if ($timeline === '') { - return 'standard'; - } - if (!in_array($timeline, self::AI_TIMELINES, true)) { - throw new Exception('Invalid aiTimeline.'); - } - return $timeline; - } - - private function normalizeAiBatchSize(mixed $value, string $timeline): int - { - $defaults = ['priority' => 500, 'standard' => 150, 'economy' => 50]; - if ($value === null || trim((string)$value) === '') { - return $defaults[$timeline] ?? 150; - } - if (!is_numeric($value)) { - throw new Exception('Invalid aiBatchSize.'); - } - return max(1, min(500, (int)$value)); - } - - private function normalizeAiMaxCostUsd(mixed $value): ?float - { - if ($value === null || trim((string)$value) === '') { - return null; - } - if (!is_numeric($value)) { - throw new Exception('Invalid aiMaxCostUsd.'); - } - return max(0.0, round((float)$value, 4)); - } - - private function normalizeAiRate(mixed $value, float $default): float - { - if ($value === null || trim((string)$value) === '') { - return $default; - } - if (!is_numeric($value)) { - throw new Exception('Invalid AI pricing input.'); - } - return max(0.0, round((float)$value, 4)); - } - - private function wilsonLowerBound(int $successes, int $total): float - { - if ($total < 1) { - return 0.0; - } - $z = 1.959963984540054; - $p = $successes / $total; - $zSquared = $z * $z; - $denominator = 1 + ($zSquared / $total); - $centre = $p + ($zSquared / (2 * $total)); - $margin = $z * sqrt((($p * (1 - $p)) + ($zSquared / (4 * $total))) / $total); - return max(0.0, ($centre - $margin) / $denominator); - } - - private function normalizeHallIds(array $hallIds): array - { - return self::normalizeHallScope($hallIds); - } - - private function quotedHallIds(array $hallIds): string - { - global $db; - return implode(',', array_map( - static fn(string $id): string => "'" . $db->escape_string($id) . "'", - $hallIds - )); - } - - private function uuidV4(): string - { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - } -} diff --git a/services/nginx/app/cli.php b/services/nginx/app/cli.php index 2e84875b..a6905cb8 100644 --- a/services/nginx/app/cli.php +++ b/services/nginx/app/cli.php @@ -106,10 +106,6 @@ if ($args[1] === 'run') { echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n"; (new \classes\cron_worker())->run(); break; - case 'xlvask-automation-migrate': - echo "[" . date('Y-m-d H:i:s') . "][XLVASK] Ensuring automation schema readiness\n"; - require_once 'cron/EnsureXLVaskAutomationSchema.php'; - break; default: echo "Invalid script name"; break; diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 56b48502..58f677c5 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -150,12 +150,6 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'SyncXLVaskModuleCron', ], - 'ProcessXLVaskAutopilotQueueCron' => [ - 'interval' => 60, - 'last_run' => 0, - 'next_run' => 0, - 'function' => 'ProcessXLVaskAutopilotQueueCron', - ], 'SystemSearchCacheMaintenanceCron' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, @@ -679,15 +673,6 @@ function SyncXLVaskModuleCron(): void } } -function ProcessXLVaskAutopilotQueueCron(): array -{ - $xlvask = new xlvask(); - if (!$xlvask->config->enabled->isTrue()) { - return []; - } - return $xlvask->getTasks()->processAutopilotQueue(3); -} - function EconomicTransferQueueCron(): void { try { diff --git a/services/nginx/app/cron/EnsureXLVaskAutomationSchema.php b/services/nginx/app/cron/EnsureXLVaskAutomationSchema.php deleted file mode 100644 index dfb6ce59..00000000 --- a/services/nginx/app/cron/EnsureXLVaskAutomationSchema.php +++ /dev/null @@ -1,71 +0,0 @@ - false, - 'db_target' => $dbTarget, - 'preflight' => null, - 'applied' => false, - 'postflight' => null, - 'wash_id_uniqueness_ready' => false, - 'wash_id_uniqueness_activated' => false, - 'wash_id_uniqueness_blocked' => false, - 'error' => null, -]; - -try { - $preflight = migration_20260804_xlvask_ai_auto_policy_v2::preflight(); - $result['preflight'] = $preflight; - - if (!(bool)($preflight['ready'] ?? false)) { - $result['postflight'] = migration_20260804_xlvask_ai_auto_policy_v2::apply(); - $result['applied'] = true; - } else { - $result['postflight'] = $preflight; - } - - $postflight = (array)$result['postflight']; - if (!(bool)($postflight['ready'] ?? false)) { - throw new RuntimeException('XL Vask automation schema is still not ready after apply.'); - } - - if (xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) { - $result['wash_id_uniqueness_ready'] = true; - } else { - $activated = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration(); - $result['wash_id_uniqueness_ready'] = $activated && xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(); - $result['wash_id_uniqueness_activated'] = $result['wash_id_uniqueness_ready']; - $result['wash_id_uniqueness_blocked'] = !$result['wash_id_uniqueness_ready']; - } - - $result['success'] = (bool)$result['wash_id_uniqueness_ready']; - if (!$result['success']) { - $result['error'] = 'Wash-id uniqueness is blocked, likely due duplicate normalized wash_id values.'; - } -} catch (Throwable $throwable) { - // The wrapper cron entry may swallow the runtime exception that is - // re-thrown below, so emit a container-log breadcrumb here too. - error_log('[cron-ensure-xlvask-automation-schema] apply failed: ' . $throwable->getMessage()); - $result['error'] = $throwable->getMessage(); -} - -echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; - -if (!$result['success']) { - throw new RuntimeException((string)($result['error'] ?: 'XL Vask schema readiness failed.')); -} - diff --git a/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php b/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php deleted file mode 100644 index 0cd9293f..00000000 --- a/services/nginx/app/modules/miniMax/config/miniMax_api_key_c.php +++ /dev/null @@ -1,29 +0,0 @@ -setupConfig('miniMax'); - $this->allowUpdate([ - miniMax_enabled_c::class, - miniMax_api_key_c::class, - ]); - $this->enabled = new miniMax_enabled_c(); - $this->api_key = new miniMax_api_key_c(); - } -} diff --git a/services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md b/services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md deleted file mode 100644 index 1eef9eec..00000000 --- a/services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md +++ /dev/null @@ -1,86 +0,0 @@ -# XL Vask AI automation runbook - -This runbook is an operator procedure. None of its gates are applied by deployment, HTTP GETs, constructors, or workers. Every production-changing step requires a human approval tied to the exact deployed backend and frontend SHAs. - -## 0. Deploy-order and rollback invariant - -The legacy attachment/creation config values are kill switches, but an old backend treats them as direct enable switches. Old code cannot interpret the new policy stages, calibration identity, rolling caps, action latches, or canary soak. Therefore old-backend traffic is forbidden whenever either legacy switch is true, including during a new-policy canary. - -Use this exact forward sequence: - -1. While the old backend is still serving, set both legacy automatic-order switches to false through the approved config procedure and verify the persisted values from every serving instance. -2. Stop/disable old XL Vask automation workers and verify there is no active automatic run. Ordinary XL Vask synchronization may continue. -3. Deploy the new backend with policy effectively `off`; verify ordinary synchronization still completes and scheduled automation no-ops while schema readiness is false. -4. Run read-only migration preflight, then the separately approved explicit additive migration. If it is partial or fails, keep the new backend deployed, policy `off`, both legacy switches false, and workers no-op; repair or complete the migration before continuing. Never route old code as a partial-migration workaround. -5. Verify migration readiness and the new backend SHA, then deploy/verify the compatible frontend. Only after that generate advisory evidence and use preview-bound policy transitions. - -Use this exact rollback sequence before any old-code traffic: - -1. Keep all traffic on the new backend, call the dedicated halt endpoint, and verify policy `halted` plus both persisted legacy switches false. -2. Stop new-backend automation workers, wait for or safely reconcile the active run, and verify no financial mutation is in flight. -3. Roll back the frontend if required, then deploy the old backend with both legacy switches still false. Verify ordinary sync only. -4. Do not re-enable either legacy switch on old code. Recovery of automatic actions requires redeploying the new policy-aware backend and repeating readiness, advisory calibration, canary, and soak. - -## 1. Read-only preflight - -1. Record the backend/frontend SHAs, environment, operator, invoice period, and scanner-hall scope. -2. Call the scoped capabilities and admin-readiness GETs with `dateFrom` and `dateTo`. -3. Confirm `migration.ready`, `missing_tables`, `missing_columns`, `missing_indexes`, `preflight_conflicts`, `worker_healthy`, WashId uniqueness, planner identity, resolved model, active run, scoped eligible counts, rolling budgets, and reviewed soak counts. -4. Stop if dates are invalid, hall scope is empty, an execute run is active, identity changed, a latch is halted, or any readiness field fails closed. - -## 2. Explicit schema migration - -Use the controlled database migration procedure to invoke only -`migration_20260804_xlvask_ai_auto_policy_v2::apply()`. First retain its read-only -`preflight()` output. Review the additive SQL and backup/restore point, approve the exact SHA, run it once, retain the returned status, and rerun readiness. Do not invoke `applyExplicitMigration()` from a request, worker, cron task, or application startup. -If preflight reports multiple legacy execute runs in `queued`, `running`, or `retry_wait`, stop. Reconcile those runs through a separately approved operational procedure; the migration never auto-resolves or modifies the conflicting run records. - -### 2a. Operator entry points - -There are two equivalent ways to apply the migration from a privileged -container with the configured DB credentials. Both call the same gated -`migration_20260804_xlvask_ai_auto_policy_v2::apply()` entry point and -produce identical status output. Pick whichever fits the workflow. - -``` -# Option A — standalone script (mirrors scripts/account-deletion-schema.php) -php scripts/xlvask-automation-migrate.php check # read-only preflight -php scripts/xlvask-automation-migrate.php apply --yes # apply, gated by --yes - -# Option B — CLI dispatcher inside index.php (defines WD + composes bootstrap) -php index.php run xlvask-automation-migrate # preflight, applies if !ready -``` - -Both exit 0 when `ready=true` and 1 otherwise. Always retain the JSON -status artifact for the audit log and rerun `check` to confirm the -postflight is green. - -## 3. WashId uniqueness - -Inspect normalized duplicate WashIds. Resolve conflicts through an independently approved data procedure. Only then use the guarded uniqueness activation with the exact typed phrase. Recheck the generated normalized column and unique index before any automatic action. - -## 4. Advisory evidence and calibration - -Keep policy at `advisory`. Run explicit `dry_run` requests to import and persist plans, or `replay` for cache-only read-only evaluation. Review suggestions in hall scope. Label exact OpenAI attach/create suggestions; model identity, prompt hash, schema hash, policy version, resolved model, and chronological label snapshot are part of the artifact identity. Generate inactive backtests, independently review qualification thresholds and contradictions, then activate the exact artifact hash with its typed phrase. - -## 5. Staged policy transitions - -Every transition uses a bounded human reason, server-generated policy preview, exact confirmation phrase, and apply-time revalidation. The reason is bound into the preview hash and retained in the immutable policy event: - -`off` -> `advisory` -> `ai_attach_canary` -> `ai_attach_verified` -> `ai_create_canary` -> `verified_capped` - -Stages may not be skipped. An active execute run, stale preview, changed policy version, changed model/planner identity, missing exact calibration, incomplete reviewed soak, invalid period scope, or exhausted readiness gate blocks promotion. - -## 6. Reviewed soak and caps - -Volume alone never completes soak. Every auto-accepted action must be adjudicated. Only explicit `correct` outcomes from the current action canary activation epoch count: 200 correct reviewed links before attach verification/create eligibility and 50 correct reviewed creates before `verified_capped`. `incorrect`, `duplicate`, `cross_hall`, or `unaudited` persistently halts the relevant action latch, invalidates the active action calibration in the same transaction, and requires investigation. Re-entering that canary creates a fresh soak epoch after a new qualifying calibration is activated. - -Caps are atomic rolling 24-hour limits: 100 links globally and 10 per hall; 20 creates globally and 3 per hall. Cap exhaustion is a normal policy stop: the suggestion remains reviewable and the execute run pauses without recording a permanent action failure. Cap reservation, policy/model/calibration revalidation, current-candidate requery, financial locks, mutation, and audit commit in one transaction. - -List responses intentionally use only persisted revision/hash eligibility and do not reconstruct same-day candidates per row. This avoids an unbounded N+1 query path. Candidate existence, uniqueness, customer/department/registration/lane/date/items/totals, and financial locks are authoritatively rebuilt during preview/apply and again inside the mutation transaction. Treat a preview/apply stale-candidate rejection as a normal fail-closed refresh signal; monitor list latency and preview rejection rates during advisory/canary. - -## 7. Halt, recovery, and rollback - -Use the dedicated halt endpoint immediately on any unexplained result, duplicate, cross-hall action, missing audit, model mismatch, financial invariant, worker lease failure, or upstream revision anomaly. Halt disables legacy compatibility switches and preserves the reason. Generic config may disable a switch but cannot enable it. - -Rollback means: follow the exact sequence in section 0; halt; stop new execute runs; retain audit/action/review evidence; reconcile affected orders and invoice collections; restore data only through a separately approved, previewed procedure; fix and redeploy; repeat advisory calibration and staged previews. Recovery from `halted` starts at `off` or `advisory` and requires new exact-SHA human approval. Never infer activation, soak completion, or production safety from green CI alone. diff --git a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php deleted file mode 100644 index 66c029a2..00000000 --- a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php +++ /dev/null @@ -1,52 +0,0 @@ -setVariableValueInternal($value); - } - - public function setFromAutomationPolicy(bool $enabled): void - { - self::$policyWrite = true; - try { - $this->setVariableValueInternal($enabled); - } finally { - self::$policyWrite = false; - } - } -} diff --git a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php deleted file mode 100644 index d99fd75f..00000000 --- a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php +++ /dev/null @@ -1,52 +0,0 @@ -setVariableValueInternal($value); - } - - public function setFromAutomationPolicy(bool $enabled): void - { - self::$policyWrite = true; - try { - $this->setVariableValueInternal($enabled); - } finally { - self::$policyWrite = false; - } - } -} diff --git a/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php deleted file mode 100644 index c855351c..00000000 --- a/services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php +++ /dev/null @@ -1,29 +0,0 @@ - 'xlvask.autopilot_queue', - 'legacy_name' => 'ProcessXLVaskAutopilotQueueCron', - 'name' => 'Process XL Vask autopilot queue', - 'description' => 'Claims and processes a bounded batch of queued XL Vask autopilot runs.', - 'module' => 'xlvask', - 'handler' => 'ProcessXLVaskAutopilotQueueCron', - 'schedule' => ['type' => 'interval', 'seconds' => 60], - 'timeout_seconds' => 300, - 'estimated_duration_ms' => 5000, - 'priority' => 40, - ], - [ - 'id' => 'xlvask.sync_module', - 'legacy_name' => 'SyncXLVaskModuleCron', - 'name' => 'Sync XL Vask module', - 'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.', - 'module' => 'xlvask', - 'handler' => 'SyncXLVaskModuleCron', - 'schedule' => ['type' => 'interval', 'seconds' => 3600], - 'timeout_seconds' => 900, - 'estimated_duration_ms' => 10000, - 'priority' => 95, - ], -]; diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php b/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php index eef40aaa..d7bb1960 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php @@ -2,9 +2,6 @@ namespace helpers; -require_once WD . '/classes/xlvask_autopilot_service.php'; - -use classes\xlvask_autopilot_service; use Exception; use objects\orders_o; use objects\plate_scanners_o; @@ -46,9 +43,6 @@ class xlvask_tasks $this->runSyncUsage(); $this->runSyncVehicles(); $this->runCleanupTasks(); - // Automation is an optional final phase. A pending migration or an - // off/advisory policy must never interrupt the ordinary XL Vask sync. - $this->runScheduledAutomationIfReady(); }; } @@ -76,47 +70,6 @@ class xlvask_tasks }; } - /** Enqueue automatic work only after explicit migration and policy activation. */ - public function runScheduledAutomationIfReady(): array - { - try { - $migrationStatus = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus(); - if (!(bool)($migrationStatus['ready'] ?? false)) { - return []; - } - $hallIds = $this->configuredHallIds(); - if ($hallIds === []) { - return []; - } - $autopilot = new xlvask_autopilot_service(); - $capabilities = (new \classes\xlvask_automation_policy_service())->capabilitiesReadOnly(null, null, $hallIds); - if (!xlvask_autopilot_service::scheduledExecutionAllowed($migrationStatus, $capabilities)) { - return []; - } - $autopilot->createRun(['mode' => 'execute'], null, $hallIds); - return $autopilot->processQueuedRuns(3); - } catch (\Throwable) { - // Fail closed for automation while preserving the completed ordinary sync. - return []; - } - } - - /** Drain the durable autopilot queue without running the hourly upstream synchronization. */ - public function processAutopilotQueue(int $limit = 3): array - { - $xlvask = new \classes\xlvask(); - $xlvask->requireModuleEnabled(); - if (!$xlvask->config->synchronization_enabled->isTrue()) { - return []; - } - if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) { - return []; - } - // Off/advisory cannot contain execute runs because createRun is server-gated. - // Explicit dry-run/replay evidence may still drain in advisory mode. - return (new xlvask_autopilot_service())->processQueuedRuns($limit); - } - /** Hall GUIDs are configuration, not derived from already-imported usage rows. */ private function configuredHallIds(): array { @@ -328,10 +281,8 @@ class xlvask_tasks { global $db; $xlvask = new \classes\xlvask(); - $orders_o = new orders_o(); $matches = new xlvask_potential_order_matches_o(); $linked = 0; - $ordersCreated = 0; $dateFromSql = $dateFrom !== null && $dateFrom !== '' ? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'" @@ -352,8 +303,6 @@ class xlvask_tasks } $rows = $db->fetch_all($result); - $createEnabled = $xlvask->config->automatic_order_creation_enabled->isTrue(); - foreach ($rows as $row) { $log = (new $xlvask->helpers->xlvask_usage_log())->setProperties($row); $washId = (string)$log->WashId; @@ -374,25 +323,10 @@ class xlvask_tasks (int)$log->getDepartment()->id, ); $linked++; - continue; - } - - // No matching order — try to create one if automatic creation is on. - if (!$createEnabled || !$log->isEligibleForAutomaticContinuance(false)) { - continue; - } - try { - $customer = $log->getCustomer(); - $order = (new self())->createOrderFromWash($log, $customer); - if ($order !== null) { - $ordersCreated++; - } - } catch (Exception $e) { - error_log('[xlvask-tasks] auto-create order failed for wash ' . $washId . ': ' . $e->getMessage()); } } - return ['linked' => $linked, 'orders_created' => $ordersCreated]; + return ['linked' => $linked, 'orders_created' => 0]; } private static function formatUsageLogs(array $getUsageLog): array @@ -502,9 +436,5 @@ class xlvask_tasks $xlvask = new \classes\xlvask(); // Require the module to be enabled $xlvask->requireModuleEnabled(); - if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) { - return; - } - (new xlvask_autopilot_service())->pruneExpiredData(); } } diff --git a/services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php b/services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php deleted file mode 100644 index aa3e5775..00000000 --- a/services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php +++ /dev/null @@ -1,27 +0,0 @@ -allowUpdate([ xlvask_enabled_c::class, xlvask_synchronization_enabled_c::class, - xlvask_automatic_order_attachment_enabled_c::class, - xlvask_automatic_order_creation_enabled_c::class, - xlvask_minimax_integration_enabled_c::class, - xlvask_openai_integration_enabled_c::class, xlvask_username_c::class, xlvask_password_c::class ]); $this->enabled = new xlvask_enabled_c(); $this->synchronization_enabled = new xlvask_synchronization_enabled_c(); - $this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c(); - $this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c(); - $this->minimax_integration_enabled = new xlvask_minimax_integration_enabled_c(); - $this->openai_integration_enabled = new xlvask_openai_integration_enabled_c(); $this->username = new xlvask_username_c(); $this->password = new xlvask_password_c(); } diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 336702c9..278d39ae 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -569,4 +569,81 @@ class xlvask_usage_logs_o extends db )); return $vehicles; } + + /** + * Read-only per-period usage-log summary used by the Selvvask view. + * Replaces the legacy autopilot-service summary with a thin SQL aggregate + * over xlvask_usage_logs that stays well within the operator's hall scope. + * + * @param array $allowedHallIds + * @return array{counts: array, total_net_amount: float, ignored_count: int, window: array{from:?string,to:?string}} + */ + public function summarizeUsageOrdersReadOnly(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array + { + global $db; + if ($allowedHallIds === []) { + return [ + 'counts' => ['total' => 0, 'needs_review' => 0, 'ignored' => 0], + 'total_net_amount' => 0.0, + 'ignored_count' => 0, + 'window' => ['from' => $dateFrom, 'to' => $dateTo], + ]; + } + $hallSql = implode(',', array_map( + static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'", + $allowedHallIds + )); + $fromSql = $dateFrom !== null && $dateFrom !== '' + ? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'" + : 'DATE_SUB(NOW(), INTERVAL 30 DAY)'; + $toSql = $dateTo !== null && $dateTo !== '' + ? "'" . $db->escape_string((string)$dateTo) . " 23:59:59'" + : 'NOW()'; + + $sql = "SELECT + COUNT(*) AS total, + SUM(CASE WHEN ignored_at IS NULL THEN 1 ELSE 0 END) AS needs_review, + SUM(CASE WHEN ignored_at IS NOT NULL THEN 1 ELSE 0 END) AS ignored_count + FROM xlvask_usage_logs + WHERE StartTime >= {$fromSql} + AND StartTime <= {$toSql} + AND FinishStatus = 1 + AND HallId IN ({$hallSql})"; + $result = $db->query($sql); + $row = ($result !== false && $result->num_rows > 0) + ? $db->fetch_all($result)[0] + : ['total' => 0, 'needs_review' => 0, 'ignored_count' => 0]; + + $netSql = "SELECT + COALESCE(SUM( + CAST( + REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].PriceIncVat')), '\"', '') AS DECIMAL(10,2) + ) + - COALESCE( + CAST( + REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].Vat')), '\"', '') AS DECIMAL(10,2) + ), 0 + ) + ), 0) AS period_net + FROM xlvask_usage_logs + WHERE StartTime >= {$fromSql} + AND StartTime <= {$toSql} + AND FinishStatus = 1 + AND HallId IN ({$hallSql})"; + $netResult = $db->query($netSql); + $netRow = ($netResult !== false && $netResult->num_rows > 0) + ? $db->fetch_all($netResult)[0] + : ['period_net' => 0]; + + return [ + 'counts' => [ + 'total' => (int)($row['total'] ?? 0), + 'needs_review' => (int)($row['needs_review'] ?? 0), + 'ignored' => (int)($row['ignored_count'] ?? 0), + ], + 'total_net_amount' => round((float)($netRow['period_net'] ?? 0), 2), + 'ignored_count' => (int)($row['ignored_count'] ?? 0), + 'window' => ['from' => $dateFrom, 'to' => $dateTo], + ]; + } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 1c4fc047..d2ddec12 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -10659,372 +10659,16 @@ paths: '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } - /modules/xlvask/services/usage/autopilot-runs: - post: + /modules/xlvask/services/usage/orders/{id}/ignore: + patch: tags: - Modules - summary: Create an XLVask usage autopilot run - description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run imports and persists plans without automatic execution; replay is cache-only and read-only. - operationId: createXlvaskUsageAutopilotRun - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [mode] - properties: - dateFrom: - type: string - format: date - dateTo: - type: string - format: date - ids: - type: array - items: - type: integer - limit: - type: integer - minimum: 1 - maximum: 500 - forceRefetch: - type: boolean - mode: - type: string - enum: [execute, dry_run, replay] - idempotency_key: - type: string - maxLength: 191 - responses: - '202': - description: XLVask usage autopilot run queued successfully - content: - application/json: - schema: - type: object - properties: - run: - type: object - properties: - id: - type: integer - status: - type: string - phase: - type: string - mode: - type: string - processed: - type: integer - total: - type: integer - summary: - type: object - additionalProperties: - type: integer - warning: - type: string - error: - type: string - created_at: - type: string - nullable: true - started_at: - type: string - nullable: true - finished_at: - type: string - nullable: true - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/autopilot-runs/{id}: - get: - tags: - - Modules - summary: Get an XLVask usage autopilot run - description: Returns status and summary metadata for a previously requested autopilot run. - operationId: getXlvaskUsageAutopilotRun + summary: Ignore an XL Vask usage log + description: Mark an XL Vask usage log as ignored for invoice-period flagging. The change is scoped to the operator's hall scope and recorded with operator id and reason. + operationId: ignoreXlvaskUsageOrder parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - '200': - description: XLVask usage autopilot run returned successfully - content: - application/json: - schema: - type: object - properties: - run: - type: object - '400': - description: Invalid XLVask autopilot run id - '404': - description: XLVask autopilot run not found - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/decisions/preview: - post: - tags: - - Modules - summary: Preview an XLVask automation decision - description: Creates a short-lived preview token for applying bulk accept, deny, ignore, or link decisions after source revision revalidation. - operationId: previewXlvaskUsageAutomationDecision - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - usage_log_ids: - type: array - items: - type: integer - action: - type: string - enum: [accept, attach_order, create_order, deny, ignore] - suggestion_id: - type: integer - nullable: true - order_id: - type: integer - nullable: true - reason: - type: string - responses: - '200': - description: XLVask automation decision preview created successfully - content: - application/json: - schema: - type: object - properties: - preview: - type: object - properties: - id: { type: string } - selection_hash: { type: string } - requires_confirmation: { type: boolean } - confirmation_phrase: - type: string - nullable: true - description: Opaque preview-issued phrase that must be submitted exactly when confirmation is required. - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/decisions/apply: - post: - tags: - - Modules - summary: Apply an XLVask automation decision preview - description: Applies a previewed decision inside a transactional policy boundary after source hash, expected version, hall scope, and selection hash are revalidated. - operationId: applyXlvaskUsageAutomationDecision - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [preview_id, selection_hash] - properties: - preview_id: - type: string - selection_hash: - type: string - confirmation_text: - type: string - responses: - '200': - description: XLVask automation decision applied successfully - content: - application/json: - schema: - type: object - properties: - applied: - type: integer - results: - type: array - items: - type: object - failed: - type: array - items: - type: object - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/readiness: - get: - tags: [Modules] - summary: Inspect XLVask automation activation readiness - description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts. - operationId: getXlvaskAutomationActivationReadiness - parameters: - - { in: query, name: dateFrom, required: false, schema: { type: string, format: date } } - - { in: query, name: dateTo, required: false, schema: { type: string, format: date } } - responses: - '200': - description: Activation readiness returned successfully - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/capabilities: - get: - tags: [Modules] - summary: Inspect effective XLVask automation capabilities - operationId: getXlvaskAutomationCapabilities - parameters: - - { in: query, name: dateFrom, required: false, schema: { type: string, format: date } } - - { in: query, name: dateTo, required: false, schema: { type: string, format: date } } - responses: - '200': - description: Permission-aware capabilities, stage, readiness, active run, budgets, and reviewed soak returned. - content: - application/json: - schema: - type: object - properties: - effective_action_sources: - type: array - description: Empty unless automatic financial actions are currently effective; OpenAI is the only supported source. - items: { type: string, enum: [openai] } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/autopilot-runs/active: - get: - tags: [Modules] - summary: Inspect the active XLVask execute run - operationId: getActiveXlvaskUsageAutopilotRun - responses: - '200': - description: "Returns {run: null} or the oldest active execute run." - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/policy/previews: - post: - tags: [Modules] - summary: Preview an XLVask server-policy stage transition - operationId: previewXlvaskAutomationPolicyTransition - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [target_stage, reason] - properties: - target_stage: - type: string - enum: [off, advisory, ai_attach_canary, ai_attach_verified, ai_create_canary, verified_capped] - reason: - type: string - minLength: 1 - maxLength: 1000 - responses: - '200': { description: Short-lived, readiness-bound policy preview returned. } - '409': { description: Stage ordering, calibration, active run, schema, uniqueness, or reviewed-soak gate blocked the transition. } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/policy/apply: - post: - tags: [Modules] - summary: Apply a previewed XLVask server-policy transition - operationId: applyXlvaskAutomationPolicyTransition - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [preview_id, selection_hash, confirmation_text] - properties: - preview_id: { type: string, format: uuid } - selection_hash: { type: string } - confirmation_text: { type: string } - responses: - '200': { description: Policy and re-evaluated readiness returned. } - '409': { description: Preview expired or policy, identity, calibration, run, or readiness changed. } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/halt: - post: - tags: [Modules] - summary: Immediately halt XLVask automatic actions - operationId: haltXlvaskAutomation - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - reason: { type: string, maxLength: 1000 } - responses: - '200': { description: Automatic actions halted and kill switches disabled atomically. } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/calibrations/labels: - post: - tags: [Modules] - summary: Adjudicate one exact XLVask suggestion - description: Stores an administrator-adjudicated correct or incorrect label bound to one suggestion ID. - operationId: adjudicateXlvaskCalibrationLabel - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [suggestion_id, outcome] - properties: - suggestion_id: { type: integer } - outcome: { type: string, enum: [correct, incorrect, duplicate, cross_hall, unaudited] } - responses: - '200': { description: Calibration label stored successfully } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/calibrations/backtest: - post: - tags: [Modules] - summary: Generate an inactive XLVask calibration artifact - description: Uses exact adjudicated labels and a chronological 80/20 holdout; generation never activates the artifact. - operationId: generateXlvaskCalibrationArtifact - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [segment_key] - properties: - segment_key: { type: string } - responses: - '200': { description: Inactive calibration artifact generated successfully } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - - /modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate: - post: - tags: [Modules] - summary: Activate a qualifying XLVask calibration artifact - operationId: activateXlvaskCalibrationArtifact - parameters: - - in: path - name: id + - name: id + in: path required: true schema: { type: integer } requestBody: @@ -11033,35 +10677,94 @@ paths: application/json: schema: type: object - required: [artifact_hash, confirmation_text] + required: [reason] properties: - artifact_hash: { type: string } - confirmation_text: { type: string } + reason: + type: string + maxLength: 500 responses: - '200': { description: Calibration artifact activated successfully } - '401': { $ref: '#/components/responses/Unauthorized' } + '200': + description: XL Vask usage log marked as ignored + '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } - /modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate: + /modules/xlvask/services/usage/orders/{id}/unignore: post: - tags: [Modules] - summary: Activate guarded wash-id uniqueness - description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts. - operationId: activateXlvaskWashIdUniqueness + tags: + - Modules + summary: Clear ignore metadata on an XL Vask usage log + description: Resets ignored_at, ignored_by and ignored_reason on an XL Vask usage log scoped to the operator's hall scope. + operationId: unignoreXlvaskUsageOrder + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '200': + description: Ignore metadata cleared + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /modules/xlvask/services/usage/orders/{id}/accept: + post: + tags: + - Modules + summary: Convert an XL Vask usage log into an order + description: Creates an order from the XL Vask usage log in the operator's hall scope and records the converted usage log as ignored with a stable reason referencing the order id. + operationId: acceptXlvaskUsageOrder + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '200': + description: Order created from XL Vask usage log + content: + application/json: + schema: + type: object + properties: + order_id: { type: integer } + usage_log_id: { type: integer } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { description: XL Vask customer is not linkable } + + /modules/xlvask/services/usage/orders/{id}/reject: + post: + tags: + - Modules + summary: Reject an XL Vask usage log with a reviewer note + description: Marks the XL Vask usage log as ignored with a reviewer-provided reason. Scoped to the operator's hall scope. + operationId: rejectXlvaskUsageOrder + parameters: + - name: id + in: path + required: true + schema: { type: integer } requestBody: required: true content: application/json: schema: type: object - required: [confirmation_text] + required: [reason] properties: - confirmation_text: { type: string } + reason: + type: string + maxLength: 500 responses: - '200': { description: Wash-id uniqueness activated successfully } - '409': { description: Duplicate wash IDs or schema readiness blocked activation } - '401': { $ref: '#/components/responses/Unauthorized' } + '200': + description: XL Vask usage log rejected + '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + /modules/action-logs: get: diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index b99546dc..bb240b0a 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -952,44 +952,6 @@ class moduleConfigRoute 'modules_openai_config' => 'Update openai config' ] ); - /** MiniMax config > GET */ - $this->get('/minimax/config', function () { - global $response; - $this->requirePermission('modules_minimax_config'); - $user = (new authentication())->get_user(); - if ($user) { - (new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully fetched MiniMax config'); - $response->success( - (new \classes\minimax())->config->getConfigRequest() - ); - } else { - (new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session'); - $response->error('Invalid session', 400); - } - }, - [ - 'modules_minimax_config' => 'Get MiniMax config' - ] - ); - /** MiniMax config > POST */ - $this->post('/minimax/config', function () { - global $response; - $this->requirePermission('modules_minimax_config'); - $user = (new authentication())->get_user(); - if ($user) { - (new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully updated MiniMax config'); - $response->success( - (new \classes\minimax())->config->postConfigRequest() - ); - } else { - (new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session'); - $response->error('Invalid session', 400); - } - }, - [ - 'modules_minimax_config' => 'Update MiniMax config' - ] - ); /** LicensePlateRecognizer config > GET */ $this->get('/licenseplaterecognizer/config', function () { global $response; diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index 7ae8b8ba..fb7c142c 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -2,15 +2,11 @@ namespace routes; -require_once WD . '/classes/xlvask_autopilot_service.php'; - use classes\authentication; use classes\response; use classes\router; use classes\xlvask; -use classes\xlvask_autopilot_service; use objects\orders_o; -use objects\users_o; use objects\xlvask_customers_o; use traits\route_t; @@ -180,10 +176,6 @@ class moduleXLVaskRoute $this->get('/modules/xlvask/tasks/sync-usage', function () { global $response; $this->requirePermission('modules_xlvask_sync_usage'); - // Remove the memory limit - // ini_set('memory_limit', '-1'); - // Remove the execution time limit - // set_time_limit(300); // Create the xlvask tasks object $xlvask = new xlvask(); // Run the sync usage task @@ -199,27 +191,6 @@ class moduleXLVaskRoute ] ); - $this->get('/modules/xlvask/tasks/debug', function () { - global $response; - $this->requirePermission('modules_xlvask_sync_usage'); - // Create the xlvask tasks object - $xlvask = new xlvask(); - $user = new users_o(); - $user->getUserByCustomerNumber(12345679); - //$result = $xlvask->getTasks()->runSyncVehicles(false); - $vehicles = $xlvask->new($xlvask->helpers->xlvask_vehicles); - //print_r($vehicles::getVehicleByRegistrationNumber('BW93159')); - // Response - $response->success( - 'Debugging xlvask tasks', - 200 - ); - }, - [ - 'modules_xlvask_sync_usage' => 'Synchronize usage with the xlvask module' - ] - ); - $this->get('/modules/xlvask/tasks/import-customers', function () { global $response; $this->requirePermission('modules_xlvask_import_customers'); @@ -251,15 +222,5 @@ class moduleXLVaskRoute 'modules_xlvask_import_vehicles' => 'Import vehicles from the xlvask module' ] ); - - $this->get('/modules/xlvask/tasks/import-usage', function () { - global $response; - $this->requirePermission('modules_xlvask_import_usage'); - $response->error('Deprecated state-changing GET. Use POST /modules/xlvask/services/usage/autopilot-runs.', 410); - }, - [ - 'modules_xlvask_import_usage' => 'Import usage from the xlvask module' - ] - ); } } diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index cac57f8c..6c7a9475 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -2,24 +2,10 @@ namespace routes; -require_once WD . '/classes/xlvask_automation_service.php'; -require_once WD . '/classes/xlvask_autopilot_service.php'; -require_once WD . '/classes/xlvask_automation_policy_service.php'; - use classes\authentication; -use classes\redis; use classes\response; -use classes\stripe; use classes\xlvask; -use classes\xlvask_autopilot_service; -use classes\xlvask_automation_service; -use classes\xlvask_automation_policy_service; -use objects\collected_order_invoices_o; -use objects\departments_o; -use objects\economic_module_orders; use objects\orders_o; -use objects\stripe_module_orders_o; -use objects\stripe_payment_intents_o; use objects\xlvask_usage_logs_o; use traits\route_t; @@ -30,136 +16,110 @@ class xlvaskUsageLogsRoute public function run(): void { $this->get('/modules/xlvask/services/usage/orders', function () { - // Define the permissions: - $permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter) - $permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter) - $response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly) - // Require the user to be logged in + $permission_list_own = 'list_xlvask_usage_orders_own'; + $permission_list_all = 'list_xlvask_usage_orders_all'; + $response_includes_items = false; global $response; if (!$this->hasPermission($permission_list_all)) { $this->requirePermission($permission_list_own); } - // Get the user object $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - $allowedHallIds = $this->allowedHallIdsForUser($user); - if ($allowedHallIds === []) { - $response->error('No XL Vask hall scope is available', 403); - return; - } - $xlvask_usage_logs = new xlvask_usage_logs_o(); - $xlvask = new xlvask(); - $automation_service = new xlvask_automation_service(); - $linked_order_ids_by_wash_id = []; - $xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment(); - $orders_o = new orders_o(); - $xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log); - // Increase the memory limit to 512MB (Provided it's currently less than that) - if (ini_get('memory_limit') < '5120M') { - ini_set('memory_limit', '5120M'); - } - // Return the list of usage logs - $result = $xlvask_usage_logs - // Make sure the Customer is not in the default customers list - ->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')") - ->listObjectsWithPaginationIfSet( - function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { - // Remove the 'id' field from the log - $id = (int)$log['id']; - $automation = $automation_service->readAutomationStateByUsageLogId($id, $log); - $amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log); - unset($log['id']); - // Convert the 'WashItems' field from JSON to an array - $log['WashItems'] = json_decode($log['WashItems'], true); - $usage_log_payload = array_intersect_key($log, array_flip([ - 'WashId', - 'CustomerId', - 'Customer', - 'VatNumber', - 'Location', - 'Hall', - 'HallId', - 'StartTime', - 'FinishTime', - 'RegistrationNumber', - 'VehicleType', - 'IdentificationType', - 'IdentificationId', - 'Info', - 'Updated', - 'Prepaid', - 'FinishStatus', - 'CustomerGuid', - 'VehicleId', - 'WashItems', - 'ignored_at', - 'ignored_by', - 'ignored_reason', - ])); - // Create a new xlvask usage log object - $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); - // Set the properties of the temporary object - $tmp->setProperties($usage_log_payload); - $wash_id = (string)$tmp->WashId; - if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { - $linked_order = (new orders_o())->selectByWashId($wash_id); - $linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null; - } - $linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null; - // Define the result structure - $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); - // Return the result - $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); - $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order - $tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount']; - $tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name']; - $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; - // Clear memory - unset($tmp); - // Return the result - return [ - 'id' => $id, // Return the ID of the log - 'fast_link_key' => null, - 'automation' => $automation, - 'source_hash' => $log['source_hash'] ?? null, - 'source_revision' => $log['source_revision'] ?? null, - 'source_observed_at' => $log['source_observed_at'] ?? null, - 'source_stable_since' => $log['source_stable_since'] ?? null, - 'source_observation_count' => (int)($log['source_observation_count'] ?? 0), - 'import_state' => $log['import_state'] ?? 'unchanged', - 'resolution_state' => $log['resolution_state'] ?? 'needs_review', - 'certainty' => $log['certainty'] ?? 'none', - 'planned_action' => $log['planned_action'] ?? 'none', - 'state_reason' => $log['state_reason'] ?? null, - 'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1, - 'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null, - 'last_evaluated_at' => $log['last_evaluated_at'] ?? null, - ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) - 'usage_log_id' => $id, - 'linked_order_id' => $linked_order_id, - ]; - }, - $xlvask_usage_logs->forceRestrictFilters( - [ - // This makes sure that the user can only see department logs that belong to their departments - 'HallId' => $allowedHallIds, - 'FinishStatus' => ['1'], // Only show finished logs - ] - ) - ); - // Return the response - $response->success($result); - } else { - // Return an error + if (!$user) { $response->error('Invalid session', 400); } - }, - [ - 'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)', - 'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)', - ] - ); + $allowedHallIds = $this->allowedHallIdsForUser($user); + if ($allowedHallIds === []) { + $response->error('No XL Vask hall scope is available', 403); + } + $xlvask_usage_logs = new xlvask_usage_logs_o(); + $xlvask = new xlvask(); + $linked_order_ids_by_wash_id = []; + $xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log; + if (ini_get('memory_limit') < '5120M') { + ini_set('memory_limit', '5120M'); + } + $result = $xlvask_usage_logs + ->setAdditionalWhereClause( + "`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')" + ) + ->listObjectsWithPaginationIfSet( + function ($log) use ( + $response_includes_items, + $xlvask_usage_logs, + $xlvask, + &$linked_order_ids_by_wash_id + ) { + $id = (int)$log['id']; + $amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log); + unset($log['id']); + $log['WashItems'] = json_decode($log['WashItems'] ?? '[]', true); + $usage_log_payload = array_intersect_key($log, array_flip([ + 'WashId', + 'CustomerId', + 'Customer', + 'VatNumber', + 'Location', + 'Hall', + 'HallId', + 'StartTime', + 'FinishTime', + 'RegistrationNumber', + 'VehicleType', + 'IdentificationType', + 'IdentificationId', + 'Info', + 'Updated', + 'Prepaid', + 'FinishStatus', + 'CustomerGuid', + 'VehicleId', + 'WashItems', + 'ignored_at', + 'ignored_by', + 'ignored_reason', + ])); + $tmp = $xlvask->new($xlvask_usage_log_class); + $tmp->setProperties($usage_log_payload); + $wash_id = (string)$tmp->WashId; + if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { + $linked_order = (new orders_o())->selectByWashId($wash_id); + $linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null + ? (int)$linked_order->id + : null; + } + $linked_order_id = $wash_id !== '' + ? $linked_order_ids_by_wash_id[$wash_id] + : null; + $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); + $tmp_res = $isEligibleForAutomaticContinuance + ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) + : []; + $tmp_res['order']['customer_name'] = $tmp->Customer; + $tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount']; + $tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name']; + $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; + unset($tmp); + return [ + 'id' => $id, + 'fast_link_key' => null, + 'usage_log_id' => $id, + 'linked_order_id' => $linked_order_id, + 'ignored_at' => $log['ignored_at'] ?? null, + 'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null, + 'ignored_reason' => $log['ignored_reason'] ?? null, + ...$tmp_res['order'], + ]; + }, + $xlvask_usage_logs->forceRestrictFilters([ + 'HallId' => $allowedHallIds, + 'FinishStatus' => ['1'], + ]) + ); + $response->success($result); + }, [ + 'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)', + 'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)', + ]); $this->get('/modules/xlvask/services/usage/orders/summary', function () { global $response; @@ -172,392 +132,189 @@ class xlvaskUsageLogsRoute } $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; - - $response->success([ - 'summary' => (new xlvask_autopilot_service())->getSummary( - $dateFrom, - $dateTo, - $this->allowedHallIdsForUser($user) - ), - ]); - }, - [ - 'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary', - 'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries', - ] - ); - - $this->post('/modules/xlvask/services/usage/autopilot-runs', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - - $input = []; - foreach ([ - 'ids', - 'dateFrom', - 'dateTo', - 'limit', - 'forceRefetch', - 'mode', - 'idempotency_key', - 'aiTimeline', - 'aiBatchSize', - 'aiMaxCostUsd', - 'aiInputUsdPer1mUsd', - 'aiOutputUsdPer1mUsd', - ] as $key) { - if ($this->isParametersSet([$key])) { - $input[$key] = $this->getParameter($key); - } - } - - $response->success([ - 'run' => (new xlvask_autopilot_service())->createRun( - $input, - (int)$user->id, - $this->allowedHallIdsForUser($user) - ), - ], 202); - }, - [ - 'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run', - ] - ); - - $this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; - $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; - $response->success((new xlvask_automation_policy_service())->readinessReadOnly( + $allowedHallIds = $this->allowedHallIdsForUser($user); + $summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly( $dateFrom, $dateTo, - $this->allowedHallIdsForUser($user) - )); - }, [ - 'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness', - ]); - - $this->get('/modules/xlvask/services/usage/automation/capabilities', function () { - global $response; - if (!$this->hasPermission('list_xlvask_usage_orders_all') - && !$this->hasPermission('list_xlvask_usage_orders_own')) { - $this->requirePermission('list_xlvask_usage_orders_own'); - } - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; - $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; - $service = new xlvask_automation_policy_service(); - $capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user)); - $canManage = $this->hasPermission('manage_xlvask_usage_automation'); - $canReview = $canManage - || $this->hasPermission('review_xlvask_usage_order') - || $this->hasPermission('list_xlvask_usage_orders_all') - || $this->hasPermission('list_xlvask_usage_orders_own'); - $canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate'); - $response->success([ - 'can_view' => true, - 'can_review' => $canReview, - 'can_dry_run' => $canManage, - 'can_execute' => $canManage && in_array('execute', $capabilities['allowed_modes'], true), - 'can_manage_policy' => $canManagePolicy, - 'can_halt' => $canManagePolicy, - ...$capabilities, - ]); - }, [ - 'list_xlvask_usage_orders_own' => 'Inspect XL Vask automation capabilities', - 'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer', - ]); - - $this->get('/modules/xlvask/services/usage/autopilot-runs/active', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $response->success(['run' => (new xlvask_automation_policy_service())->activeRunReadOnly( - $this->allowedHallIdsForUser($user) - )]); - }, ['manage_xlvask_usage_automation' => 'Inspect the active XL Vask automation run']); - - $this->post('/modules/xlvask/services/usage/automation/admin/policy/previews', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['target_stage', 'reason']); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $response->success(['preview' => (new xlvask_automation_policy_service())->createPolicyPreview( - (string)$this->getParameter('target_stage'), - (string)$this->getParameter('reason'), - (int)$user->id - )]); - }, ['superuser_xlvask_automation_activate' => 'Preview an XL Vask automation stage transition']); - - $this->post('/modules/xlvask/services/usage/automation/admin/policy/apply', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['preview_id', 'selection_hash', 'confirmation_text']); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $response->success((new xlvask_automation_policy_service())->applyPolicyPreview([ - 'preview_id' => $this->getParameter('preview_id'), - 'selection_hash' => $this->getParameter('selection_hash'), - 'confirmation_text' => $this->getParameter('confirmation_text'), - ], (int)$user->id)); - }, ['superuser_xlvask_automation_activate' => 'Apply a previewed XL Vask automation stage transition']); - - $this->post('/modules/xlvask/services/usage/automation/admin/halt', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $reason = $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : ''; - $response->success((new xlvask_automation_policy_service())->halt((int)$user->id, $reason)); - }, ['superuser_xlvask_automation_activate' => 'Immediately halt XL Vask automatic actions']); - - $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['segment_key']); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $response->success([ - 'artifact' => (new xlvask_autopilot_service())->generateCalibrationArtifact( - trim((string)$this->getParameter('segment_key')), - (int)$user->id - ), - ]); - }, [ - 'superuser_xlvask_automation_activate' => 'Generate an inactive XL Vask historical calibration artifact', - ]); - - $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/labels', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['suggestion_id', 'outcome']); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $allowedHallIds = $this->allowedHallIdsForUser($user); - $result = (new xlvask_autopilot_service())->adjudicateCalibrationLabel( - (int)$this->getParameter('suggestion_id'), - trim((string)$this->getParameter('outcome')), - (int)$user->id, $allowedHallIds ); - $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; - $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; - $response->success([ - ...$result, - 'readiness' => (new xlvask_automation_policy_service())->readinessReadOnly($dateFrom, $dateTo, $allowedHallIds), - ]); + $response->success(['summary' => $summary]); }, [ - 'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence', - ]); - - $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['artifact_hash', 'confirmation_text']); - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $response->success((new xlvask_autopilot_service())->activateCalibration( - (int)($this->fromRoute('id') ?? 0), - trim((string)$this->getParameter('artifact_hash')), - (string)$this->getParameter('confirmation_text'), - (int)$user->id - )); - }, [ - 'superuser_xlvask_automation_activate' => 'Explicitly activate a qualifying XL Vask calibration artifact', - ]); - - $this->post('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate', function () { - global $response; - $this->requirePermission('superuser_xlvask_automation_activate'); - self::requireParameters(['confirmation_text']); - $response->success((new xlvask_autopilot_service())->activateWashIdUniqueness( - (string)$this->getParameter('confirmation_text') - )); - }, [ - 'superuser_xlvask_automation_activate' => 'Explicitly activate the guarded XL Vask wash-id uniqueness migration', - ]); - - $this->get('/modules/xlvask/services/usage/autopilot-runs/{id}', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - $id = (int)($this->fromRoute('id') ?? 0); - if ($id < 1) { - $response->error('Invalid XL Vask autopilot run id', 400); - } - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $run = (new xlvask_autopilot_service())->getRun( - $id, - (int)$user->id, - $this->allowedHallIdsForUser($user) - ); - $response->success(['run' => $run]); - }, - [ - 'manage_xlvask_usage_automation' => 'Read XL Vask usage-log autopilot run status', - ] - ); - - $this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () { - global $response; - if (!$this->hasPermission('manage_xlvask_usage_automation') - && !$this->hasPermission('review_xlvask_usage_order')) { - $this->requirePermission('manage_xlvask_usage_automation'); - } - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $input = []; - foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'] as $key) { - if ($this->isParametersSet([$key])) { - $input[$key] = $this->getParameter($key); - } - } - $response->success([ - 'preview' => (new xlvask_autopilot_service())->createDecisionPreview( - $input, - (int)$user->id, - $this->allowedHallIdsForUser($user) - ), - ]); - }, [ - 'manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision', - 'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer', - ]); - - $this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () { - global $response; - if (!$this->hasPermission('manage_xlvask_usage_automation') - && !$this->hasPermission('review_xlvask_usage_order')) { - $this->requirePermission('manage_xlvask_usage_automation'); - } - $user = (new authentication())->get_user(); - if (!$user) { - $response->error('Invalid session', 400); - } - $input = []; - foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) { - if ($this->isParametersSet([$key])) { - $input[$key] = $this->getParameter($key); - } - } - $response->success( - (new xlvask_autopilot_service())->applyDecision( - $input, - (int)$user->id, - $this->allowedHallIdsForUser($user) - ) - ); - }, [ - 'manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision', - 'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer', + 'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary', + 'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries', ]); $this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () { global $response; - $this->requirePermission('ignore_xlvask_usage_order'); - $response->error('Use the server-generated automation decision preview and apply endpoints.', 409); - }, - [ - 'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging', - ] - ); - - $this->post('/modules/xlvask/services/usage/orders/automation/run', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - $response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410); - }, - [ - 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', - ] - ); - - $this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - $response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410); - }, - [ - 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', - ] - ); - - $this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () { - global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - + $this->requirePermission('review_xlvask_usage_order'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } - $id = (int)($this->fromRoute('id') ?? 0); if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } - self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user)); + self::requireParameters(['reason']); + $reason = trim((string)$this->getParameter('reason')); + if (mb_strlen($reason) > 500) { + $response->error('Reason is too long (max 500 characters)', 400); + } + $allowedHallIds = $this->allowedHallIdsForUser($user); + self::requireUsageLogInHallScope($id, $allowedHallIds); + $log = new xlvask_usage_logs_o(); + $log->getById($id); + if (!$log->id) { + $response->error('XL Vask usage log not found', 404); + } + $log->ignored_at->set(date('Y-m-d H:i:s')); + $log->ignored_by->set((int)$user->id); + $log->ignored_reason->set($reason); + $log->objectChanged(); + $response->success([ + 'id' => $id, + 'ignored_at' => $log->ignored_at->get(), + 'ignored_by' => (int)$log->ignored_by->get(), + 'ignored_reason' => $log->ignored_reason->get(), + ]); + }, [ + 'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging', + ]); - $response->error('Use the server-generated automation decision preview and apply endpoints.', 409); - }, - [ - 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', - ] - ); - - $this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () { + $this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () { global $response; - $this->requirePermission('manage_xlvask_usage_automation'); - + $this->requirePermission('review_xlvask_usage_order'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } - $id = (int)($this->fromRoute('id') ?? 0); if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } - self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user)); + $allowedHallIds = $this->allowedHallIdsForUser($user); + self::requireUsageLogInHallScope($id, $allowedHallIds); + $log = new xlvask_usage_logs_o(); + $log->getById($id); + if (!$log->id) { + $response->error('XL Vask usage log not found', 404); + } + $log->ignored_at->set(null); + $log->ignored_by->set(null); + $log->ignored_reason->set(null); + $log->objectChanged(); + $response->success(['id' => $id]); + }, [ + 'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log', + ]); - $response->error('Use the server-generated automation decision preview and apply endpoints.', 409); - }, - [ - 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', - ] - ); + $this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () { + global $response; + $this->requirePermission('review_xlvask_usage_order'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + } + $allowedHallIds = $this->allowedHallIdsForUser($user); + self::requireUsageLogInHallScope($id, $allowedHallIds); + $log = new xlvask_usage_logs_o(); + $log->getById($id); + if (!$log->id) { + $response->error('XL Vask usage log not found', 404); + } + $xlvask = new xlvask(); + $log_helper = new ($xlvask->helpers->xlvask_usage_log)(); + $log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([ + 'WashId', + 'CustomerId', + 'Customer', + 'VatNumber', + 'Location', + 'Hall', + 'HallId', + 'StartTime', + 'FinishTime', + 'RegistrationNumber', + 'VehicleType', + 'IdentificationType', + 'IdentificationId', + 'Info', + 'Updated', + 'Prepaid', + 'FinishStatus', + 'CustomerGuid', + 'VehicleId', + 'WashItems', + ]))); + $customer = $log_helper->getCustomer(); + if ($customer === null) { + $response->error('XL Vask customer is not linkable', 422); + } + if (empty($customer->externId)) { + $response->error('XL Vask customer has no external id', 422); + } + $tmp_user = $customer->getUser(); + if (!$tmp_user) { + $response->error('XL Vask customer is not provisioned in this system', 422); + } + try { + $order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer); + } catch (\Throwable $e) { + error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage()); + $response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422); + } + $log->ignored_at->set(date('Y-m-d H:i:s')); + $log->ignored_by->set((int)$user->id); + $log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id); + $log->objectChanged(); + $response->success([ + 'order_id' => (int)$order->id, + 'usage_log_id' => $id, + ]); + }, [ + 'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order', + ]); + + $this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () { + global $response; + $this->requirePermission('review_xlvask_usage_order'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + } + self::requireParameters(['reason']); + $reason = trim((string)$this->getParameter('reason')); + if (mb_strlen($reason) > 500) { + $response->error('Reason is too long (max 500 characters)', 400); + } + $allowedHallIds = $this->allowedHallIdsForUser($user); + self::requireUsageLogInHallScope($id, $allowedHallIds); + $log = new xlvask_usage_logs_o(); + $log->getById($id); + if (!$log->id) { + $response->error('XL Vask usage log not found', 404); + } + $log->ignored_at->set(date('Y-m-d H:i:s')); + $log->ignored_by->set((int)$user->id); + $log->ignored_reason->set('Rejected: ' . $reason); + $log->objectChanged(); + $response->success([ + 'id' => $id, + 'ignored_at' => $log->ignored_at->get(), + 'ignored_by' => (int)$log->ignored_by->get(), + 'ignored_reason' => $log->ignored_reason->get(), + ]); + }, [ + 'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note', + ]); $this->get('/modules/xlvask/services/usage/orders/fast-link', function () { global $response; @@ -566,91 +323,55 @@ class xlvaskUsageLogsRoute if (!$user) { $response->error('Invalid session', 400); } - self::requireParameters([ - 'fast_link_key', // Example: 'temporary_cache_6878cf0603d77' - ]); - // Get the fast link key from the request + self::requireParameters(['fast_link_key']); $fast_link_key = (string)self::getParameter('fast_link_key'); self::requireType($fast_link_key, self::type_string()); - self::requireMinLength('fast_link_key', 20); // Minimum length of the fast link key - self::requireMaxLength('fast_link_key', 50); // Maximum length of the fast link key - // Check if the fast link key is valid - // First, check if the key has the correct format - if (preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) { - // Get the cached data from Redis - $cached_data = redis->get($fast_link_key); - // Check if the cached data is valid - if ($cached_data) { - // Decode the cached data - $data = json_decode($cached_data, true); - // Check if the data is valid - if (is_array($data)) { - $allowedHallIds = $this->allowedHallIdsForUser($user); - if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) { - $response->error('Fast link is outside the current XL Vask hall scope', 403); - } - // Delete the cached data from Redis - redis->delete($fast_link_key); - // Return the data - $xlvask = new xlvask(); - $order_arr = (new orders_o())->simulateOrderFromXLVask($xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), true); // ['order' => $order_arr, 'order_items' => $items_arr] - $tmp_order_obj = (object)[]; - $tmp_order_arr = $order_arr['order'] ?? []; - /** - * "id": -1, - * "customer_id": 39159000, - * "cashier_id": 2285, - * "reference": "Simulated Order from XL Vask", - * "notes": "This is a simulated order generated from an XL Vask usage log", - * "department_id": 1, - * "reg_1": "DE55248", - * "reg_2": "", - * "reg_3": "", - * "completed_at": null, - * "created_at": "2025-07-16 13:04:54", - * "deleted_at": null, - * "total_net_amount": 683, - * "invoice_collection_id": 0, - * "booking_id": 0, - * "wash_id": "b24728ea-e22b-4dce-8cd3-a0998f7fdc5e", - * "lane": 1, - * "closed_at": null - */ - $tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0; - $tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0; - $tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? ''; - $tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? ''; - $tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? ''; - $tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? ''; - $tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? ''; - $tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0; - $response->success([ - ...$order_arr, - 'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound( - $tmp_order_obj->reg_1, - $tmp_order_obj->reg_2, - $tmp_order_obj->reg_3, - $tmp_order_obj->department_id, - $tmp_order_obj->created_at, - ), - ]); - } else { - // Return an error if the data is not valid - $response->error('Invalid cached data', 400); - } - } else { - // Return an error if the fast link key does not exist in Redis - $response->error('Fast link key not found', 404); - } - } else { - // Return an error if the fast link key is invalid + self::requireMinLength('fast_link_key', 20); + self::requireMaxLength('fast_link_key', 50); + if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) { $response->error('Invalid fast link key format', 400); } - }, - [ - 'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77' - ] - ); + $cached_data = redis->get($fast_link_key); + if (!$cached_data) { + $response->error('Fast link key not found', 404); + } + $data = json_decode($cached_data, true); + if (!is_array($data)) { + $response->error('Invalid cached data', 400); + } + $allowedHallIds = $this->allowedHallIdsForUser($user); + if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) { + $response->error('Fast link is outside the current XL Vask hall scope', 403); + } + redis->delete($fast_link_key); + $xlvask = new xlvask(); + $order_arr = (new orders_o())->simulateOrderFromXLVask( + $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), + true + ); + $tmp_order_obj = (object)[]; + $tmp_order_arr = $order_arr['order'] ?? []; + $tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0; + $tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0; + $tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? ''; + $tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? ''; + $tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? ''; + $tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? ''; + $tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? ''; + $tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0; + $response->success([ + ...$order_arr, + 'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound( + $tmp_order_obj->reg_1, + $tmp_order_obj->reg_2, + $tmp_order_obj->reg_3, + $tmp_order_obj->department_id, + $tmp_order_obj->created_at, + ), + ]); + }, [ + 'fast_link_key' => 'string', + ]); } private function allowedHallIdsForUser(object $user): array diff --git a/services/nginx/app/tests/Api/XLVaskReviewApiTest.php b/services/nginx/app/tests/Api/XLVaskReviewApiTest.php deleted file mode 100644 index fbf60a3a..00000000 --- a/services/nginx/app/tests/Api/XLVaskReviewApiTest.php +++ /dev/null @@ -1,153 +0,0 @@ -createUserSession(['list_xlvask_usage_orders_own']); - - $response = api_client()->get( - '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', - $operator['headers'] - ); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $payload = $response->data(); - - expect($payload)->toBeArray(); - // Operator with list_xlvask_usage_orders_own must light can_review so the - // Selvvask view actually renders the Accept / Reject / Ignore buttons. - expect($payload['can_review'] ?? null)->toBeTrue(); - // The AI-administrator-only flags must stay false so the operator can - // never trigger the autopilot or change policy from the selvvask view. - expect($payload['can_dry_run'] ?? null)->toBeFalse(); - expect($payload['can_execute'] ?? null)->toBeFalse(); - expect($payload['can_manage_policy'] ?? null)->toBeFalse(); - expect($payload['can_halt'] ?? null)->toBeFalse(); -}); - -it('lights can_review for an operator granted the dedicated review_xlvask_usage_order permission (and the list permission to reach the endpoint)', function (): void { - api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-permission'); - - $operator = api_fixtures()->createUserSession([ - 'review_xlvask_usage_order', - 'list_xlvask_usage_orders_own', - ]); - - $response = api_client()->get( - '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', - $operator['headers'] - ); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $payload = $response->data(); - expect($payload['can_review'] ?? null)->toBeTrue(); - // Review-only grant must NOT unlock AI admin powers. - expect($payload['can_dry_run'] ?? null)->toBeFalse(); - expect($payload['can_execute'] ?? null)->toBeFalse(); - expect($payload['can_manage_policy'] ?? null)->toBeFalse(); -}); - -it('keeps can_review false for an operator with no xlvask permissions', function (): void { - api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'auth'); - api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'failure'); - api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-blocked'); - - $operator = api_fixtures()->createUserSession(['list_departments']); - - $response = api_client()->get( - '/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31', - $operator['headers'] - ); - - // The endpoint should 403 because the user lacks the list_* permission - // required to even inspect the capabilities surface. - $response - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMissingPermissions(['list_xlvask_usage_orders_own']); -}); - -it('admits the operator to the decisions preview endpoint with review_xlvask_usage_order', function (): void { - api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'happy'); - api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-allows'); - - $operator = api_fixtures()->createUserSession([ - 'review_xlvask_usage_order', - 'list_xlvask_usage_orders_own', - ]); - - // Post against a known-bad usage_log_id. The exact validation failure - // does not matter; we only assert that the operator is NOT 403'd at - // the permission gate. A 4xx or 5xx response from the downstream - // autopilot service is the expected "got past the gate" signal. - $response = api_client()->post( - '/modules/xlvask/services/usage/automation/decisions/preview', - [ - 'usage_log_ids' => [99999999], - 'action' => 'accept', - 'force_manual' => true, - ], - $operator['headers'], - ); - - // Permission gate is what we care about — anything other than 403 means - // the operator got past it. The downstream autopilot service may - // return 4xx (validation) or 5xx (idempotency / token) for a synthetic - // usage log id; both are acceptable for this contract test. - $status = $response->status; - expect($status)->not->toBe(403); -}); - -it('rejects an operator without the new permission from the decisions preview endpoint', function (): void { - api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'auth'); - api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'failure'); - api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-blocks'); - - $operator = api_fixtures()->createUserSession(['list_xlvask_usage_orders_own']); - - $response = api_client()->post( - '/modules/xlvask/services/usage/automation/decisions/preview', - [ - 'usage_log_ids' => [99999999], - 'action' => 'accept', - 'force_manual' => true, - ], - $operator['headers'], - ); - - // list_xlvask_usage_orders_own is enough to inspect capabilities, but - // NOT enough to post a decision — only review_xlvask_usage_order and - // manage_xlvask_usage_automation can. The route must 403. - $response - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMissingPermissions(['manage_xlvask_usage_automation']); -}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index 11ffc966..6c984221 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -24,8 +24,6 @@ return [ 'GET /superuser/departments/{id}/overview', 'PUT /superuser/department/branding', 'POST /bird/voice/calls/webhook/inbound', - 'GET /modules/xlvask/services/usage/automation/capabilities', - 'POST /modules/xlvask/services/usage/automation/decisions/preview', ], 'manual_operations' => [ 'GET /ping', diff --git a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php index b2cb974e..d0535971 100644 --- a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php +++ b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php @@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void { $registry = new cron_task_registry(app_path('modules')); $definitions = $registry->definitions(); - expect($definitions)->toHaveCount(24); + expect($definitions)->toHaveCount(22); expect(array_keys($definitions))->toContain( 'system.sync_logs', 'backups.process_jobs', @@ -16,10 +16,10 @@ it('discovers module-owned cron task definitions', function (): void { 'dynamicimages.pre_render', 'weatherapi.preload_department_responses', 'goals.progress_alerts', - 'xlvask.autopilot_queue', 'account.process_deletion_requests', 'selfserve.activate_opening_cleaner_relays' ); + expect(array_keys($definitions))->not->toContain('xlvask.autopilot_queue'); $transferQueue = $registry->get('EconomicTransferQueueCron'); expect($transferQueue)->not->toBeNull(); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php deleted file mode 100644 index 338ee2f2..00000000 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php +++ /dev/null @@ -1,65 +0,0 @@ -toContain("case 'xlvask-automation-migrate':") - ->toContain("require_once 'cron/EnsureXLVaskAutomationSchema.php'"); -}); - -it('keeps the cron entry point gated by the WD constant and the migration class', function (): void { - $cron = file_get_contents(WD . '/cron/EnsureXLVaskAutomationSchema.php'); - expect($cron) - ->toContain("if (!defined('WD'))") - ->toContain('migration_20260804_xlvask_ai_auto_policy_v2::preflight') - ->toContain('migration_20260804_xlvask_ai_auto_policy_v2::apply') - ->toContain('xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration'); -}); - -it('keeps the migration class operator-only and references the bootstrap entry point', function (): void { - $migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php'); - expect($migration) - ->toContain('operator-invoked') - ->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration') - ->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus'); -}); - -it('documents both operator entry points in the XL Vask automation runbook', function (): void { - $runbook = file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md'); - expect($runbook) - ->toContain('## 2. Explicit schema migration') - ->toContain('## 2a. Operator entry points') - ->toContain('scripts/xlvask-automation-migrate.php') - ->toContain("php index.php run xlvask-automation-migrate"); -}); - -it('keeps the standalone xlvask automation migration script gated and idempotent when the repo root is mounted', function (): void { - $repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS'); - if ($repoRoot === false || $repoRoot === '') { - expect(true)->toBeTrue(); // covered by CI; local docker lacks the repo-root bind mount - return; - } - - $scriptPath = realpath($repoRoot . '/scripts/xlvask-automation-migrate.php'); - expect($scriptPath)->not->toBeFalse(); - - $source = file_get_contents($scriptPath); - expect($source) - ->toContain("if (PHP_SAPI !== 'cli')") - ->toContain("Refusing schema mutation without: apply --yes") - ->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration') - ->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus'); -}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php deleted file mode 100644 index 782fc36e..00000000 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ /dev/null @@ -1,805 +0,0 @@ -toBe('EC21233'); -}); - -it('builds stable XL Vask automation item signatures', function (): void { - $items = [ - ['product_id' => 20, 'quantity' => 1, 'price' => 275], - ['product_id' => 10, 'quantity' => 2, 'price' => 649], - ['product_id' => 20, 'quantity' => 1, 'price' => 0], - ]; - - expect(xlvask_automation_service::itemSignaturePartsForAutomation($items)) - ->toBe([ - '10:2:649', - '20:1:0', - '20:1:275', - ]); -}); - -it('identifies strict price agreement matches by product, quantity, and total', function (): void { - $usageItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519], - ['product_id' => 24, 'quantity' => 1, 'price' => 39], - ['product_id' => 21, 'quantity' => 1, 'price' => 79], - ]; - $orderItems = [ - ['product_id' => 21, 'quantity' => 1, 'price' => 79], - ['product_id' => 10, 'quantity' => 1, 'price' => 519], - ['product_id' => 24, 'quantity' => 1, 'price' => 39], - ]; - - expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems)) - ->toBeTrue(); -}); - -it('rejects price agreement automation when product lines differ despite equal total', function (): void { - $usageItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519], - ['product_id' => 24, 'quantity' => 1, 'price' => 39], - ]; - $orderItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519], - ['product_id' => 50, 'quantity' => 1, 'price' => 39], - ]; - - expect(xlvask_automation_service::itemsTotalForAutomation($usageItems)) - ->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems)) - ->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems)) - ->toBeFalse(); -}); - -it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void { - $row = xlvask_automation_service::normalizeUsageLogRowForAutomation([ - 'id' => 47086, - 'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb', - 'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]', - ]); - - expect($row) - ->not->toHaveKey('id') - ->and($row['WashItems'])->toBe([ - [ - 'OriginalProductName' => 'Bus', - 'Count' => 1, - ], - ]); -}); - -it('builds stable OpenAI cache keys for identical automation input', function (): void { - $prompt = 'Prompt'; - $schemaName = 'xlvask_automation'; - $schema = [ - 'required' => ['action'], - 'properties' => [ - 'confidence' => ['type' => 'number'], - 'action' => ['type' => 'string'], - ], - ]; - $schemaWithDifferentKeyOrder = [ - 'properties' => [ - 'action' => ['type' => 'string'], - 'confidence' => ['type' => 'number'], - ], - 'required' => ['action'], - ]; - $payloadA = [ - 'usage_log' => [ - 'registration' => 'AB12345', - 'creation_allowed' => true, - ], - 'candidate_orders' => [ - ['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]], - ], - ]; - $payloadB = [ - 'candidate_orders' => [ - ['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10], - ], - 'usage_log' => [ - 'creation_allowed' => true, - 'registration' => 'AB12345', - ], - ]; - - expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1)) - ->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1)); -}); - -it('changes OpenAI cache keys when automation eligibility input changes', function (): void { - $schema = ['type' => 'object']; - $newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']]; - $olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']]; - - expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1)) - ->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1)); -}); - -it('declares a persistent OpenAI cache table for XL Vask automation', function (): void { - $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - - expect($bootstrapContent) - ->toContain('xlvask_automation_openai_cache') - ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); -}); - -it('declares auditable and idempotent XL Vask autopilot run tables', function (): void { - $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - - expect($bootstrapContent) - ->toContain('xlvask_autopilot_runs') - ->toContain('xlvask_autopilot_run_items') - ->toContain('xlvask_automation_audit') - ->toContain('UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') - ->toContain('updated_at'); -}); - -it('declares cached amount summary columns for XL Vask usage logs', function (): void { - $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - - expect($bootstrapContent) - ->toContain('cached_total_net_amount') - ->toContain('cached_primary_product_name') - ->toContain('cached_amount_at'); -}); - -it('keeps automatic XL Vask execution scoped to exact attachments', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - - expect($serviceContent) - ->toContain('openAiAttachHardGuardsPass($suggestion, $context)') - ->toContain('self::isExactItemMatchForAutomation((array)($context[\'items\'] ?? []), (array)($candidate[\'order_items\'] ?? []))') - ->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;") - ->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';"); -}); - -it('keeps automatic order creation behind calibration and uniqueness readiness gates', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - - expect($serviceContent) - ->toContain('if ($action === self::ACTION_CREATE) {') - ->toContain('automatic_order_creation_enabled->isTrue()') - ->toContain("(string)(\$suggestion['certainty'] ?? '') !== 'certain'") - ->toContain('washIdUniquenessReady()'); -}); - -it('never classifies missing or undersized calibration evidence as certain', function (): void { - expect(xlvask_automation_service::classifyCertaintyForAutomation([]))->toBe('uncertain') - ->and(xlvask_automation_service::classifyCertaintyForAutomation([ - 'active' => true, - 'precision_value' => 0.999, - 'wilson_lower_bound' => 0.99, - 'holdout_examples' => 40, - 'overall_examples' => 199, - 'segment_examples' => 30, - 'contradictions' => 0, - ]))->toBe('uncertain'); -}); - -it('classifies only a qualifying contradiction-free calibration artifact as certain', function (): void { - $artifact = [ - 'active' => true, - 'precision_value' => 0.995, - 'wilson_lower_bound' => 0.98, - 'holdout_examples' => 200, - 'overall_examples' => 200, - 'segment_examples' => 30, - 'contradictions' => 0, - ]; - - expect(xlvask_automation_service::classifyCertaintyForAutomation($artifact))->toBe('certain') - ->and(xlvask_automation_service::classifyCertaintyForAutomation($artifact, true, ['conflict']))->toBe('uncertain'); -}); - -it('requires two source observations and a six-hour stable window for automatic actions', function (): void { - $now = strtotime('2026-08-03 12:00:00'); - $stable = [ - 'source_observation_count' => 2, - 'source_observed_at' => '2026-08-03 11:55:00', - 'source_stable_since' => '2026-08-03 06:00:00', - ]; - expect(xlvask_automation_service::sourceIsStableForAutomatic($stable, $now))->toBeTrue() - ->and(xlvask_automation_service::sourceIsStableForAutomatic([ - ...$stable, 'source_observation_count' => 1, - ], $now))->toBeFalse() - ->and(xlvask_automation_service::sourceIsStableForAutomatic([ - ...$stable, 'source_stable_since' => '2026-08-03 06:00:01', - ], $now))->toBeFalse(); -}); - -it('builds order-independent revision hashes for XL Vask source payloads', function (): void { - $first = ['WashId' => 'wash-1', 'Customer' => 'A', 'WashItems' => [['Count' => 1, 'Name' => 'Vask']]]; - $second = ['WashItems' => [['Name' => 'Vask', 'Count' => 1]], 'Customer' => 'A', 'WashId' => 'wash-1']; - - expect(xlvask_usage_logs_o::sourceHashForAutomation($first)) - ->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second)); -}); - -it('treats reordered XL Vask wash items as the same source revision', function (): void { - $first = ['WashId' => 'wash-1', 'WashItems' => [ - ['ProductId' => 2, 'Count' => 1], - ['ProductId' => 1, 'Count' => 2], - ]]; - $second = ['WashId' => 'wash-1', 'WashItems' => [ - ['Count' => 2, 'ProductId' => 1], - ['Count' => 1, 'ProductId' => 2], - ]]; - expect(xlvask_usage_logs_o::sourceHashForAutomation($first)) - ->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second)); -}); - -it('uses the existing OpenAI module with strict no-retention planner settings', function (): void { - $openAi = file_get_contents(WD . '/classes/openai.php'); - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - - expect($openAi)->toContain("'store' => false") - ->toContain("'role' => 'developer'") - ->toContain("'role' => 'user'") - ->toContain("if (\$status !== 'completed')") - ->toContain("=== 'refusal'") - ->toContain("'_openai_usage' => [") - ->and($automation)->toContain("private const PLANNER_MODEL = 'MiniMax-M3'") - ->toContain('candidate_order_id') - ->not->toContain('opaque_context_id') - ->not->toContain("'product_name' =>"); -}); - -it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void { - $client = file_get_contents(WD . '/modules/xlvask/classes/xlvask_request.php'); - - expect($client)->toContain('CURLOPT_SSL_VERIFYPEER, true') - ->toContain('CURLOPT_SSL_VERIFYHOST, 2') - ->not->toContain('Headers: " . implode') - ->not->toContain('Response: $response'); -}); - -it('uses a dedicated queued XL Vask autopilot service with scoped durable runs', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - - expect($serviceContent) - ->toContain('public function createRun(') - ->toContain('public function processQueuedRuns(') - ->toContain('public function getRun(') - ->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)') - ->toContain('scope_hall_ids_json') - ->toContain('ai_timeline') - ->toContain('ai_batch_size') - ->toContain('ai_max_cost_usd') - ->toContain('lease_expires_at') - ->toContain('attempt_count') - ->toContain('next_attempt_at') - ->toContain('$renewLease') - ->toContain("phase = 'retry_wait'"); -}); - -it('rejects execute run creation unless current server readiness allows execute mode', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - expect($serviceContent) - ->toContain("if (\$mode === 'execute')") - ->toContain('capabilitiesReadOnly(') - ->toContain("!in_array('execute', (array)(\$capabilities['allowed_modes'] ?? []), true)"); -}); - -it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void { - expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([ - 'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true, - ])->and(xlvask_autopilot_service::modeCapabilities('dry_run'))->toBe([ - 'import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true, - ])->and(xlvask_autopilot_service::modeCapabilities('replay'))->toBe([ - 'import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true, - ]); -}); - -it('preserves GUID hall scopes and rejects empty scope values at runtime', function (): void { - expect(xlvask_autopilot_service::normalizeHallScope([ - ' 845d29a1-a7d2-4e3b-bbc3-2b13242d744a ', '', '845d29a1-a7d2-4e3b-bbc3-2b13242d744a', - ]))->toBe(['845d29a1-a7d2-4e3b-bbc3-2b13242d744a']); -}); - -it('keeps explicit retry keys idempotent and actor-bound', function (): void { - expect(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-a')) - ->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-b')) - ->not->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 11, 'nonce-a')) - ->and(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-a')) - ->not->toBe(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-b')); - expect(xlvask_autopilot_service::requestFingerprint(['mode' => 'execute', 'ids' => [1]])) - ->not->toBe(xlvask_autopilot_service::requestFingerprint(['mode' => 'dry_run', 'ids' => [1]])); -}); - -it('fails preview snapshots closed when source hash or optimistic version changes', function (): void { - $current = ['expected_version' => 4, 'source_hash' => str_repeat('a', 64)]; - expect(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('a', 64), $current))->toBeTrue() - ->and(xlvask_autopilot_service::previewSnapshotMatches(5, str_repeat('a', 64), $current))->toBeFalse() - ->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 64), $current))->toBeFalse(); -}); - -it('requires explicit run modes and serializes active execute runs in schema', function (): void { - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - expect($autopilot) - ->toContain("(\$input['mode'] ?? '')") - ->toContain('An explicit XL Vask autopilot mode is required.') - ->and($schema) - ->toContain('active_execute_slot') - ->toContain('uniq_xlvask_active_execute_run'); -}); - -it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - expect($serviceContent) - ->toContain('INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id') - ->toContain('chronological_80_20_by_suggestion_created_at_and_id') - ->toContain('xlvask_automation_calibration_label_events') - ->toContain("'label_snapshot' => \$snapshot") - ->not->toContain("SUM(f.decision = 'accepted')"); -}); - -it('keeps XL Vask automation execution inside a transactional revalidation boundary', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - - expect($serviceContent) - ->toContain('$connection->begin_transaction()') - ->toContain('$connection->commit()') - ->toContain('$connection->rollback()') - ->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE') - ->toContain("findSameDayCandidateOrders(\$lockedLog, (array)\$context['proposed_order'], true)") - ->toContain('SELECT id FROM order_items WHERE order_id IN (') - ->toContain("if (\$action === self::ACTION_CREATE && \$currentCandidates !== [])") - ->toContain("WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{\$washId}')), '') FOR UPDATE") - ->toContain('XL Vask-kildedata blev ændret efter evalueringen.'); -}); - -it('permits only identity-bound calibrated OpenAI automatic actions behind deterministic hard guards', function (): void { - $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - expect($serviceContent) - ->toContain("!== self::SOURCE_OPENAI") - ->toContain('hardGuardsPassForCertainty($suggestion, $context)') - ->toContain('openAiAttachHardGuardsPass($suggestion, $context)') - ->toContain('policyAllowsActionReadOnly($action)') - ->toContain('sourceIsStableForAutomatic($context)') - ->toContain('washIdUniquenessReady()'); -}); - -it('pins the complete planner identity and invalidates cache keys with it', function (): void { - $identity = xlvask_automation_service::automationIdentityForAutomation(); - expect($identity) - ->toMatchArray([ - 'policy_version' => 'xlvask-ai-auto-v2', - 'model' => 'MiniMax-M3', - 'prompt_version' => 'xlvask-planner-da-v2', - 'schema_version' => 'xlvask-automation-schema-v2', - 'cache_version' => 2, - ]) - ->and($identity['identity_hash'])->toHaveLength(64) - ->and($identity['prompt_hash'])->toHaveLength(64) - ->and($identity['schema_hash'])->toHaveLength(64); -}); - -it('accepts only completed structured OpenAI responses and records the resolved model', function (): void { - $parsed = openai::parseJsonTaskResponse([ - 'status' => 'completed', - 'model' => 'gpt-5.6-sol', - 'output' => [[ - 'content' => [[ - 'type' => 'output_text', - 'text' => '{"action":"none"}', - ]], - ]], - ]); - expect($parsed)->toBe([ - 'action' => 'none', - '_openai_response_model' => 'gpt-5.6-sol', - '_openai_usage' => [ - 'input_tokens' => 0, - 'output_tokens' => 0, - 'total_tokens' => 0, - 'service_tier' => '', - ], - ]); -}); - -it('fails incomplete and refusal OpenAI responses closed', function (): void { - expect(fn() => openai::parseJsonTaskResponse([ - 'status' => 'incomplete', - 'incomplete_details' => ['reason' => 'max_output_tokens'], - ]))->toThrow(\classes\openai_request_exception::class) - ->and(fn() => openai::parseJsonTaskResponse([ - 'status' => 'completed', - 'model' => 'gpt-5.6-sol', - 'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]], - ]))->toThrow(\classes\openai_request_exception::class); - - try { - openai::parseJsonTaskResponse([ - 'status' => 'incomplete', - 'incomplete_details' => ['reason' => 'max_output_tokens'], - ]); - $incompleteRetryable = null; - } catch (\classes\openai_request_exception $exception) { - $incompleteRetryable = $exception->retryable; - } - try { - openai::parseJsonTaskResponse([ - 'status' => 'completed', - 'model' => 'gpt-5.6-sol', - 'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]], - ]); - $refusalRetryable = null; - } catch (\classes\openai_request_exception $exception) { - $refusalRetryable = $exception->retryable; - } - expect($incompleteRetryable)->toBeTrue() - ->and($refusalRetryable)->toBeFalse(); -}); - -it('declares explicit migration-only schema activation and server policy controls', function (): void { - $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - $migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php'); - $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - expect($schema) - ->toContain('applyExplicitMigration') - ->toContain('migrationStatus') - ->toContain('xlvask_automation_policy_state') - ->toContain('xlvask_automation_action_events') - ->and($migration)->toContain('operator-invoked') - ->toContain('applyExplicitMigration') - ->and($policy)->toContain('ATTACH_DAILY_CAP = 100') - ->toContain('ATTACH_PER_HALL_DAILY_CAP = 10') - ->toContain('CREATE_DAILY_CAP = 20') - ->toContain('CREATE_PER_HALL_DAILY_CAP = 3') - ->toContain("review_outcome = 'correct'"); -}); - -it('fails migration readiness closed for partial runtime schema and missing active-run uniqueness', function (): void { - $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - expect($schema) - ->toContain("'required_indexes' => \$requiredIndexes") - ->toContain("'missing_indexes' => \$missingIndexes") - ->toContain("'preflight_conflicts' => \$conflicts") - ->toContain('multiple_active_execute_runs:') - ->toContain('uniq_xlvask_active_execute_run') - ->toContain("'xlvask_automation_policy_state' => [") - ->toContain("'xlvask_automation_action_events' => [") - ->toContain("'xlvask_automation_calibrations' => [") - ->toContain("'xlvask_autopilot_runs' => ["); -}); - -it('treats policy and budget stops as resumable run pauses instead of suggestion failures', function (): void { - $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - expect($policy) - ->toContain('final class xlvask_automation_control_stop') - ->toContain("'budget_exhausted'") - ->toContain("'calibration_revoked'") - ->and($automation) - ->toContain('if ($e instanceof xlvask_automation_control_stop)') - ->toContain("'control_stop' => true") - ->toContain("\$circuitBreaker = (string)(\$result['control_stop_reason'] ?? 'policy_control_stop')"); -}); - -it('makes exact adjudication retries idempotent and rejects a changed outcome', function (): void { - expect(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'correct'))->toBeTrue() - ->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'duplicate'))->toBeFalse() - ->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches(null, 'correct'))->toBeFalse(); -}); - -it('requires current usage revision and review state for row action eligibility', function (): void { - $suggestion = [ - 'status' => 'suggested', 'action' => 'attach_order', 'expected_version' => 7, - 'input_hash' => str_repeat('a', 64), - ]; - $usage = [ - 'resolution_state' => 'needs_review', 'import_state' => 'unchanged', 'ignored_at' => null, - 'FinishStatus' => 1, 'expected_version' => 7, 'source_hash' => str_repeat('a', 64), - ]; - expect(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, $usage))->toBeTrue() - ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'ignored_at' => '2026-08-04 12:00:00']))->toBeFalse() - ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'expected_version' => 8]))->toBeFalse() - ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'import_state' => 'invalid']))->toBeFalse(); -}); - -it('emits explicit fail-closed per-row action flags and supersedes ignored suggestions', function (): void { - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $projectionStart = strpos((string)$automation, 'private function readProjectionActionFlags'); - $projectionEnd = strpos((string)$automation, 'private function decodeJsonField', (int)$projectionStart); - $projection = substr((string)$automation, (int)$projectionStart, (int)$projectionEnd - (int)$projectionStart); - expect($automation) - ->toContain("'can_ignore' =>") - ->toContain("'can_attach_order' =>") - ->toContain("'can_create_order' =>") - ->toContain('suggestionMatchesCurrentUsageForReview($suggestion, $usageRow)') - ->toContain('Current candidate') - ->and($autopilot) - ->toContain("SET status = 'superseded', updated_at = NOW()") - ->toContain("WHERE usage_log_id = {\$usageId} AND status = 'suggested'"); - expect($projection)->not->toContain('$this->buildContext('); -}); - -it('keeps scheduled automation deploy-order safe without interrupting ordinary sync', function (): void { - $tasks = (string)file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php'); - $importStart = strpos($tasks, 'public function runImportTasks(): void'); - $importEnd = strpos($tasks, '/** Enqueue automatic work', (int)$importStart); - $importBody = substr($tasks, (int)$importStart, (int)$importEnd - (int)$importStart); - - expect($tasks) - ->toContain('$this->runCleanupTasks();') - ->toContain('$this->runScheduledAutomationIfReady();') - ->toContain('scheduledExecutionAllowed($migrationStatus, $capabilities)') - ->and($importBody)->not->toContain("createRun(['mode' => 'execute']"); - expect(strpos($tasks, '$this->runCleanupTasks();')) - ->toBeLessThan(strpos($tasks, '$this->runScheduledAutomationIfReady();')); - expect(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => false], ['allowed_modes' => ['execute']]))->toBeFalse() - ->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['dry_run', 'replay']]))->toBeFalse() - ->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['execute']]))->toBeTrue(); -}); - -it('propagates only retryable OpenAI failures into a durable run retry', function (): void { - $retryable = new \classes\openai_request_exception('temporary', true, 503); - $refusal = new \classes\openai_request_exception('refused', false, null); - - expect(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, 42))->toBeTrue() - ->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, null))->toBeFalse() - ->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($refusal, 42))->toBeFalse(); - - $automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php'); - expect($automation) - ->toContain('catch (openai_request_exception $exception)') - ->toContain('throw $exception;') - ->toContain('OpenAI kunne ikke levere et anvendeligt forslag.'); -}); - -it('binds financial execution to the locked suggestion revision and indexed wash id', function (): void { - $automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php'); - $usage = ['id' => 7, 'expected_version' => 3, 'source_hash' => str_repeat('a', 64)]; - $suggestion = ['usage_log_id' => 7, 'expected_version' => 3, 'input_hash' => str_repeat('a', 64)]; - - expect(xlvask_automation_service::suggestionMatchesLockedUsageForExecution($suggestion, $usage))->toBeTrue() - ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'expected_version' => 4], $usage))->toBeFalse() - ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'input_hash' => str_repeat('b', 64)], $usage))->toBeFalse() - ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'usage_log_id' => 8], $usage))->toBeFalse(); - expect($automation) - ->toContain('suggestionMatchesLockedUsageForExecution($suggestion, $lockedUsage)') - ->toContain('WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM') - ->not->toContain('WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM'); -}); - -it('binds calibration evidence and snapshots to current planner identity and resolved model', function (): void { - $autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - $identity = ['policy_version' => 'v2', 'identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current']; - $evidence = ['policy_version' => 'v2', 'planner_identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current']; - - expect(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity($evidence, $identity))->toBeTrue() - ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'planner_identity_hash' => str_repeat('b', 64)], $identity))->toBeFalse() - ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'model' => 'gpt-stale'], $identity))->toBeFalse() - ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'policy_version' => 'v1'], $identity))->toBeFalse(); - expect($autopilot) - ->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'") - ->toContain("BINARY s.model = BINARY '{\$modelSql}'") - ->toContain("'planner_identity_hash' => (string)\$label['planner_identity_hash']") - ->toContain("'resolved_model' => (string)\$label['model']") - ->toContain("(string)(\$backtest['resolved_model'] ?? '')") - ->and($policy)->toContain("(string)(\$artifact['resolved_model'] ?? '')"); -}); - -it('authorizes calibration adjudication from an action event or an exact current reviewable suggestion', function (): void { - $autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - - expect($autopilot) - ->toContain('LEFT JOIN xlvask_automation_action_events ae ON ae.suggestion_id = s.id') - ->toContain('ae.id IS NOT NULL') - ->toContain("s.status = 'suggested' AND s.source = 'openai'") - ->toContain('s.expected_version = u.expected_version AND s.input_hash = u.source_hash') - ->toContain("u.resolution_state = 'needs_review'") - ->toContain('newer.usage_log_id = s.usage_log_id AND newer.id > s.id') - ->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'") - ->toContain("BINARY s.model = BINARY '{\$modelSql}'"); -}); - -it('advertises OpenAI as the only effective automatic action source', function (): void { - $policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - expect($policy) - ->toContain("'effective_action_sources' => \$executeEnabled") - ->toContain("? ['openai']") - ->toContain(': []'); -}); - -it('documents exact safe deploy partial migration rollback and bounded list projection', function (): void { - $runbook = (string)file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md'); - expect($runbook) - ->toContain('Old code cannot interpret the new policy stages') - ->toContain('set both legacy automatic-order switches to false') - ->toContain('Never route old code as a partial-migration workaround') - ->toContain('Use this exact rollback sequence before any old-code traffic') - ->toContain('do not reconstruct same-day candidates per row') - ->toContain('authoritatively rebuilt during preview/apply'); -}); - -it('invalidates stale calibration and restarts soak at each canary activation epoch', function (): void { - $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); - expect($policy) - ->toContain("UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW()") - ->toContain("'invalidated_calibration_segment' => \$segment") - ->toContain("'ai_attach_canary' => [") - ->toContain("'ai_attach_verified' => [") - ->toContain("'ai_create_canary' => [") - ->toContain("'verified_capped' => [") - ->toContain("AND created_at >= '{\$sinceSql}'") - ->toContain("\$state['attach_activated_at'] ?? null") - ->toContain("\$state['create_activated_at'] ?? null") - ->and($autopilot) - ->toContain("'safety_epoch' => \$this->calibrationSafetyEpoch(\$segmentKey)") - ->toContain('XL Vask calibration artifact was invalidated by an action safety latch.') - ->toContain('XL Vask calibration labels changed after this artifact was generated.') - ->and($schema)->toContain("'invalidated_at'"); -}); - -it('scopes eligible suggestions and visible hall budgets to the caller revision and hall scope', function (): void { - $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); - expect($policy) - ->toContain('s.expected_version = u.expected_version') - ->toContain('s.input_hash = u.source_hash') - ->toContain('budgetSnapshotReadOnly($hallIds, $state)') - ->toContain('$visibleHallWhere') - ->toContain("AND hall_id IN ("); -}); - -it('exposes distinct pre-action review and post-action adjudication state', function (): void { - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - expect($automation) - ->toContain("'review_eligible' =>") - ->toContain("'adjudication_eligible' =>") - ->toContain("'allowed_adjudication_outcomes' =>") - ->toContain("'adjudication_outcome' =>") - ->and($autopilot) - ->toContain('reviewAutomaticActionBySuggestion(') - ->toContain('true') - ->toContain('$connection->begin_transaction()') - ->toContain("'action_halted' =>") - ->toContain("'affected_action' =>"); -}); - -it('fails malformed or reversed invoice-period readiness scopes closed', function (): void { - expect(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-01', '2026-08-31'))->toBeTrue() - ->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-02-30', '2026-03-01'))->toBeFalse() - ->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-31', '2026-08-01'))->toBeFalse(); -}); - -it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void { - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); - expect($automation) - ->toContain("ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC") - ->toContain('eligible_total') - ->and($usageLogs) - ->toContain('supersedeSuggestionsForSourceRevision') - ->toContain("SET status = 'superseded'") - ->toContain('$connection->begin_transaction()'); -}); - -it('captures the eligible population before processing and fails invalid revisions closed', function (): void { - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); - expect(strpos($automation, '$eligibleTotal =')) - ->toBeLessThan(strpos($automation, 'foreach ($rows as $row)')) - ->and($automation)->toContain("=== 'invalid'") - ->toContain("=== 'updated'") - ->toContain("=== 'recheck'") - ->toContain('$linkedOrder->asArray(true, false)') - ->toContain("'certainty' => 'certain'") - ->toContain('existing_link_semantically_revalidated') - ->toContain('linked_order_revision_mismatch') - ->and($autopilot)->toContain('has invalid source data') - ->and($usageLogs)->toContain('source_stable_since = NULL') - ->toContain('supersedeSuggestionsForSourceRevision($id)'); -}); - -it('keeps read-only replay cache-only without new OpenAI network calls', function (): void { - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); - expect($automation)->toContain("if (\$this->readOnlyEvaluation) {\n return null;"); -}); - -it('revalidates linked orders against customer department registration lane date and normalized items', function (): void { - $proposedOrder = [ - 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB 12 345', - 'lane' => 3, 'created_at' => '2026-08-03 10:00:00', - ]; - $linkedOrder = [ - 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB12345', - 'lane' => 3, 'created_at' => '2026-08-03 10:05:00', - ]; - $items = [['product_id' => 5, 'quantity' => 1, 'price' => 500]]; - expect(xlvask_automation_service::linkedOrderMatchesForAutomation($proposedOrder, $items, $linkedOrder, $items)) - ->toBeTrue() - ->and(xlvask_automation_service::linkedOrderMatchesForAutomation( - $proposedOrder, - $items, - [...$linkedOrder, 'customer_id' => 11], - $items - ))->toBeFalse(); -}); - -it('runs autopilot retention from the hourly XL Vask cleanup path', function (): void { - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - $tasks = file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php'); - expect($autopilot)->toContain('public function pruneExpiredData(): array') - ->and($tasks)->toContain('(new xlvask_autopilot_service())->pruneExpiredData();'); -}); - -it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { - $usageItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], - ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], - ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], - ['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']], - ]; - $orderItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], - ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], - ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], - ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], - ]; - - $score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems); - - expect($score['source']) - ->toBe('fuzzy') - ->and($score['confidence'])->toBeGreaterThanOrEqual(0.70) - ->and($score['confidence'])->toBeLessThan(0.92) - ->and($score['reason'])->toContain('ekstra ydelser'); -}); - -it('does not score an order with only the primary product as a matching add-on attachment', function (): void { - $usageItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], - ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], - ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], - ]; - $orderItems = [ - ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], - ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], - ]; - - expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) - ->toBe(0.0); -}); - -it('creates a manual operator suggestion with a deterministic proposal', function (): void { - $service = new xlvask_automation_service(); - $reflection = new ReflectionClass($service); - - // The constant is private but must be 'manual'. - $source = $reflection->getConstant('SOURCE_MANUAL'); - expect($source)->toBe('manual'); - - // The method must exist and be invokable on partial inputs (no AI). - expect($reflection->hasMethod('createManualSuggestion'))->toBeTrue(); - - // Calling it with an invalid action should throw, not silently accept. - expect(fn () => $service->createManualSuggestion(0, 'not_a_real_action', null, [])) - ->toThrow(Exception::class); -}); - -it('accepts force_manual in the decision preview contract', function (): void { - // Read the route to confirm the preview endpoint whitelists force_manual. - $routeFile = file_get_contents(__DIR__ . '/../../../routes/xlvaskUsageLogsRoute.php'); - expect($routeFile)->toContain("'force_manual'"); - expect($routeFile)->toContain("'usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'"); -}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php index 36031b9d..50c588fd 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -15,7 +15,7 @@ it('exposes direct linked order metadata on XL Vask usage order rows', function ->and($route)->toContain("'usage_log_id' => \$id"); }); -it('does not execute XL Vask usage automation while listing usage order rows', function (): void { +it('limits the usage-logs endpoint to the reviewer permission set', function (): void { $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); expect($route)->not->toBeFalse(); @@ -23,9 +23,21 @@ it('does not execute XL Vask usage automation while listing usage order rows', f $route = (string)$route; expect($route) - ->toContain('$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);') - ->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);') - ->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')"); + ->toContain("list_xlvask_usage_orders_own") + ->toContain("list_xlvask_usage_orders_all") + ->not->toContain('xlvask_autopilot_service') + ->not->toContain('xlvask_automation_service') + ->not->toContain('xlvask_automation_policy_service') + ->not->toContain('manage_xlvask_usage_automation') + ->not->toContain('evaluateUsageLogRow') + ->not->toContain('source_hash') + ->not->toContain('source_revision') + ->not->toContain('import_state') + ->not->toContain('resolution_state') + ->not->toContain('certainty') + ->not->toContain('planned_action') + ->not->toContain('expected_version') + ->not->toContain('last_run_id'); expect($route) ->toContain("if (\$allowedHallIds === [])") ->toContain("No XL Vask hall scope is available', 403") @@ -41,27 +53,21 @@ it('returns cached amount summaries on XL Vask usage order rows without widening expect($route) ->toContain('$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log)') - ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') - ->and($route)->toContain('$tmp->setProperties($usage_log_payload)') + ->and($route)->toContain("\$usage_log_payload = array_intersect_key(\$log, array_flip([") + ->and($route)->toContain("\$tmp->setProperties(\$usage_log_payload)") ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") ->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']") ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); }); -it('keeps automation metadata out of the strict legacy XL Vask helper payload', function (): void { +it('keeps ignore metadata out of the strict legacy XL Vask helper payload', function (): void { $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); expect($route)->not->toBeFalse(); - $payloadStart = strpos((string)$route, '$usage_log_payload = array_intersect_key('); - $payloadEnd = strpos((string)$route, '// Create a new xlvask usage log object', $payloadStart ?: 0); + $route = (string)$route; - expect($payloadStart)->not->toBeFalse() - ->and($payloadEnd)->not->toBeFalse(); - - $payloadDefinition = substr((string)$route, (int)$payloadStart, (int)$payloadEnd - (int)$payloadStart); - - expect($payloadDefinition) + expect($route) ->not->toContain("'source_hash'") ->not->toContain("'source_revision'") ->not->toContain("'import_state'") @@ -72,26 +78,26 @@ it('keeps automation metadata out of the strict legacy XL Vask helper payload', ->not->toContain("'last_run_id'"); }); -it('keeps the legacy state-changing XL Vask usage import GET non-mutating', function (): void { - $route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php'); - $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); +it('exposes review, accept, reject and ignore endpoints gated on review_xlvask_usage_order', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - expect($route) - ->not->toBeFalse() - ->and($automation)->not->toBeFalse(); + expect($route)->not->toBeFalse(); $route = (string)$route; - $automation = (string)$automation; expect($route) - ->toContain('Deprecated state-changing GET.') - ->toContain('Use POST /modules/xlvask/services/usage/autopilot-runs.') - ->not->toContain("'forceRefetch' => true") - ->not->toContain('runPending($dateFrom, $dateTo, [], 100, null)') - ->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"); + ->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'") + ->toContain("post('/modules/xlvask/services/usage/orders/{id}/unignore'") + ->toContain("post('/modules/xlvask/services/usage/orders/{id}/accept'") + ->toContain("post('/modules/xlvask/services/usage/orders/{id}/reject'") + ->toContain("requirePermission('review_xlvask_usage_order')") + ->toContain("'ignored_at' => \$log['ignored_at'] ?? null") + ->toContain("'ignored_by' => isset(\$log['ignored_by']) ? (int)\$log['ignored_by'] : null") + ->toContain("'ignored_reason' => \$log['ignored_reason'] ?? null") + ->not->toContain('Ignored at server-generated automation decision preview'); }); -it('exposes additive XL Vask autopilot run and summary routes', function (): void { +it('exposes a reviewer summary endpoint that does not invoke the autopilot service', function (): void { $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); expect($route)->not->toBeFalse(); @@ -100,66 +106,11 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi expect($route) ->toContain("get('/modules/xlvask/services/usage/orders/summary'") - ->toContain("post('/modules/xlvask/services/usage/autopilot-runs'") - ->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'") - ->toContain('(new xlvask_autopilot_service())->getSummary(') - ->toContain('(new xlvask_autopilot_service())->createRun(') - ->toContain('(new xlvask_autopilot_service())->getRun(') - ->toContain('$this->allowedHallIdsForUser($user)') - ->toContain("'aiTimeline'") - ->toContain("'aiBatchSize'") - ->toContain("'aiMaxCostUsd'") - ->toContain('], 202);'); + ->toContain('(new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(') + ->not->toContain('xlvask_autopilot_service()->getSummary('); }); -it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - expect($route) - ->toContain('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.') - ->toContain('Use the server-generated automation decision preview and apply endpoints.') - ->not->toContain("post('/modules/xlvask/services/usage/orders/automation/run', function () {\n global \$response;\n \$this->requirePermission('manage_xlvask_usage_automation');\n\n \$user") - ->not->toContain('(new xlvask_automation_service())->runPending(') - ->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById('); -}); - -it('exposes permission-aware capabilities active run and preview-bound server policy routes', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - expect($route) - ->toContain("get('/modules/xlvask/services/usage/automation/capabilities'") - ->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'") - ->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/previews'") - ->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/apply'") - ->toContain("post('/modules/xlvask/services/usage/automation/admin/halt'") - ->toContain("'can_manage_policy' => \$canManagePolicy") - ->toContain("'can_halt' => \$canManagePolicy") - ->toContain("'preview' => (new xlvask_automation_policy_service())->createPolicyPreview(") - ->toContain("self::requireParameters(['target_stage', 'reason'])") - ->toContain("(string)\$this->getParameter('reason')") - ->toContain("['run' => (new xlvask_automation_policy_service())->activeRunReadOnly("); -}); - -it('allows all-only permission and uses every configured scanner hall for all scope', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - expect($route) - ->toContain("if (!\$this->hasPermission(\$permission_list_all))") - ->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))") - ->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))") - ->toContain('private function allowedHallIdsForUser(object $user): array') - ->not->toContain('private static function allowedHallIdsForUser') - ->toContain("'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries'") - ->toContain('SELECT DISTINCT HallId FROM plate_scanners'); -}); - -it('makes direct ignore and deprecated automation routes non-mutating', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - expect($route) - ->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'") - ->toContain("response->error('Use the server-generated automation decision preview and apply endpoints.', 409)") - ->toContain("response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410)") - ->not->toContain("SET ignored_at = NOW(),"); -}); - -it('returns revision and resolution state on XL Vask usage order rows', function (): void { +it('routes legacy autopilot, calibration and policy transition paths through 410 Gone stubs', function (): void { $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); expect($route)->not->toBeFalse(); @@ -167,71 +118,35 @@ it('returns revision and resolution state on XL Vask usage order rows', function $route = (string)$route; expect($route) - ->toContain("'source_hash' => \$log['source_hash'] ?? null") - ->toContain("'source_revision' => \$log['source_revision'] ?? null") - ->toContain("'import_state' => \$log['import_state'] ?? 'unchanged'") - ->toContain("'resolution_state' => \$log['resolution_state'] ?? 'needs_review'") - ->toContain("'certainty' => \$log['certainty'] ?? 'none'") - ->toContain("'planned_action' => \$log['planned_action'] ?? 'none'") - ->toContain("'expected_version' => isset(\$log['expected_version']) ? (int)\$log['expected_version'] : 1") - ->toContain("'automation' => \$automation"); + ->not->toContain("'/modules/xlvask/services/usage/autopilot-runs'") + ->not->toContain("'/modules/xlvask/services/usage/autopilot-runs/active'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/previews'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/apply'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/halt'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/backtest'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/labels'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate'") + ->not->toContain("'/modules/xlvask/services/usage/automation/decisions/preview'") + ->not->toContain("'/modules/xlvask/services/usage/automation/decisions/apply'") + ->not->toContain("'/modules/xlvask/services/usage/automation/capabilities'") + ->not->toContain("'/modules/xlvask/services/usage/automation/admin/readiness'"); }); -it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): void { - $openApi = file_get_contents(WD . '/openapi.yaml'); - - expect($openApi)->not->toBeFalse(); - - $openApi = (string)$openApi; - - expect($openApi) - ->toContain('/modules/xlvask/services/usage/orders/summary:') - ->toContain('operationId: summarizeXlvaskUsageAutomation') - ->toContain('/modules/xlvask/services/usage/autopilot-runs:') - ->toContain('operationId: createXlvaskUsageAutopilotRun') - ->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:') - ->toContain('operationId: getXlvaskUsageAutopilotRun') - ->toContain('/modules/xlvask/services/usage/automation/decisions/preview:') - ->toContain('operationId: previewXlvaskUsageAutomationDecision') - ->toContain('/modules/xlvask/services/usage/automation/decisions/apply:') - ->toContain('operationId: applyXlvaskUsageAutomationDecision') - ->toContain('/modules/xlvask/services/usage/automation/admin/readiness:') - ->toContain('operationId: adjudicateXlvaskCalibrationLabel') - ->toContain('operationId: generateXlvaskCalibrationArtifact') - ->toContain('operationId: activateXlvaskCalibrationArtifact') - ->toContain('operationId: activateXlvaskWashIdUniqueness'); - expect($openApi) - ->toContain('operationId: getXlvaskAutomationCapabilities') - ->toContain('effective_action_sources:') - ->toContain('items: { type: string, enum: [openai] }') - ->toContain('operationId: getActiveXlvaskUsageAutopilotRun') - ->toContain('operationId: previewXlvaskAutomationPolicyTransition') - ->toContain('operationId: applyXlvaskAutomationPolicyTransition') - ->toContain('operationId: haltXlvaskAutomation') - ->toContain('required: [target_stage, reason]'); -}); - -it('wires preview-bound bulk decisions through the transactional autopilot service', function (): void { +it('allows all-only permission and uses every configured scanner hall for all scope', function (): void { $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); - $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); - expect($route) - ->not->toBeFalse() - ->and($autopilot)->not->toBeFalse(); + expect($route)->not->toBeFalse(); $route = (string)$route; - $autopilot = (string)$autopilot; expect($route) - ->toContain("post('/modules/xlvask/services/usage/automation/decisions/preview'") - ->toContain("post('/modules/xlvask/services/usage/automation/decisions/apply'") - ->toContain('createDecisionPreview(') - ->toContain('applyDecision(') - ->and($autopilot)->toContain("SELECT * FROM xlvask_automation_decision_previews WHERE id = '") - ->toContain('FOR UPDATE') - ->toContain('applyBoundDecisionWithinTransaction(') - ->toContain('expected_version') - ->toContain('source_hash'); + ->toContain("if (!\$this->hasPermission(\$permission_list_all))") + ->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))") + ->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))") + ->toContain('private function allowedHallIdsForUser(object $user): array') + ->not->toContain('private static function allowedHallIdsForUser') + ->toContain('SELECT DISTINCT HallId FROM plate_scanners'); }); it('does not let pending automation schema block ordinary invoice period operations', function (): void { @@ -242,36 +157,74 @@ it('does not let pending automation schema block ordinary invoice period operati ->toContain('XL Vask automation migration is pending'); }); -it('exposes a review_xlvask_usage_order permission on decision endpoints for the selvvask operator flow', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); +it('does not invoke the autopilot service anywhere on the usage-log listing path', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + $object = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); expect($route) - ->toContain("'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer'") - ->toContain("'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer'") - ->toContain("'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer'") - // The decisions endpoints must check both manage_xlvask_usage_automation and review_xlvask_usage_order - ->toContain("!\$this->hasPermission('manage_xlvask_usage_automation')") - ->toContain("!\$this->hasPermission('review_xlvask_usage_order')") - // The capabilities endpoint must light can_review for both manage and review permissions - ->toContain("'can_review' => \$canReview") - ->toContain("'review_xlvask_usage_order'") - ->toContain("'list_xlvask_usage_orders_all'") - ->toContain("'list_xlvask_usage_orders_own'") - // Other admin-only capabilities must remain gated on manage_xlvask_usage_automation - ->toContain("'can_dry_run' => \$canManage") - ->toContain("'can_execute' => \$canManage"); + ->not->toBeFalse() + ->and($object)->not->toBeFalse(); + + expect((string)$route) + ->not->toContain('xlvask_autopilot_service') + ->not->toContain('xlvask_automation_policy_service') + ->not->toContain('readAutomationStateByUsageLogId') + ->not->toContain('evaluateUsageLogRow'); + + expect((string)$object) + ->toContain('summarizeUsageOrdersReadOnly') + ->toContain('getAmountSummaryReadOnly'); }); -it('still requires manage_xlvask_usage_automation for the AI autopilot run lifecycle', function (): void { - $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); +it('removes the AI autopilot and policy service files entirely', function (): void { + expect(file_exists(WD . '/classes/xlvask_autopilot_service.php'))->toBeFalse(); + expect(file_exists(WD . '/classes/xlvask_automation_service.php'))->toBeFalse(); + expect(file_exists(WD . '/classes/xlvask_automation_policy_service.php'))->toBeFalse(); + expect(file_exists(WD . '/classes/minimax.php'))->toBeFalse(); + expect(is_dir(WD . '/modules/miniMax'))->toBeFalse(); + expect(file_exists(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md'))->toBeFalse(); + expect(file_exists(WD . '/cron/EnsureXLVaskAutomationSchema.php'))->toBeFalse(); +}); - // The autopilot-runs POST must remain manage-only — review_xlvask_usage_order must NOT unlock - // the AI-driven dry-run / execute pipeline. +it('removes the MiniMax config endpoints from moduleConfigRoute and the cli migrate command', function (): void { + $route = (string)file_get_contents(WD . '/routes/moduleConfigRoute.php'); expect($route) - ->toContain("post('/modules/xlvask/services/usage/autopilot-runs'") - ->toContain("\$this->requirePermission('manage_xlvask_usage_automation');") - // The autopilot-runs/{id} GET (used for status polling) must also remain manage-only. - ->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'") - // The autopilot-runs/active GET (recovery) must also remain manage-only. - ->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'"); + ->not->toContain("'/minimax/config'") + ->not->toContain('modules_minimax_config') + ->not->toContain('MiniMax config'); + + $cli = (string)file_get_contents(WD . '/cli.php'); + expect($cli) + ->not->toContain("'xlvask-automation-migrate'") + ->not->toContain('EnsureXLVaskAutomationSchema.php'); +}); + +it('exposes the simplified operator flow in OpenAPI and removes the AI autopilot surface', function (): void { + $openApi = file_get_contents(WD . '/openapi.yaml'); + + expect($openApi)->not->toBeFalse(); + + $openApi = (string)$openApi; + + expect($openApi) + ->toContain('/modules/xlvask/services/usage/orders/summary:') + ->toContain('/modules/xlvask/services/usage/orders/{id}/ignore:') + ->toContain('/modules/xlvask/services/usage/orders/{id}/unignore:') + ->toContain('/modules/xlvask/services/usage/orders/{id}/accept:') + ->toContain('/modules/xlvask/services/usage/orders/{id}/reject:') + ->not->toContain('/modules/xlvask/services/usage/autopilot-runs:') + ->not->toContain('/modules/xlvask/services/usage/automation/decisions/preview:') + ->not->toContain('/modules/xlvask/services/usage/automation/decisions/apply:') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/readiness:') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/policy/previews:') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/policy/apply:') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/halt:') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/calibrations/') + ->not->toContain('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/') + ->not->toContain('/modules/xlvask/services/usage/automation/capabilities:') + ->not->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:') + ->not->toContain('/modules/xlvask/services/usage/autopilot-runs/active:') + ->not->toContain('xlvaskAutomationPolicyService') + ->not->toContain('xlvaskAutomationService') + ->not->toContain('xlvaskAutopilotService'); });