Add governed XL Vask AI invoice automation (#343)
This commit is contained in:
@@ -57,7 +57,10 @@ class invoice_period_flag_schema_bootstrap
|
|||||||
);
|
);
|
||||||
|
|
||||||
products_schema_bootstrap::ensureTables();
|
products_schema_bootstrap::ensureTables();
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
||||||
|
// Invoice-period flags remain available while the separately operated
|
||||||
|
// XL Vask automation migration is pending. XL Vask-specific flag
|
||||||
|
// detection already fails closed when its optional schema is absent.
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ use Exception;
|
|||||||
use interfaces\openai_i;
|
use interfaces\openai_i;
|
||||||
use openAI\openAI_c;
|
use openAI\openAI_c;
|
||||||
|
|
||||||
|
class openai_request_exception extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly bool $retryable = false, public readonly ?int $httpStatus = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class openai implements openai_i
|
class openai implements openai_i
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -60,12 +68,19 @@ class openai implements openai_i
|
|||||||
// The caller owns the durable audit record. Do not retain application state at OpenAI.
|
// The caller owns the durable audit record. Do not retain application state at OpenAI.
|
||||||
'store' => false,
|
'store' => false,
|
||||||
'input' => [
|
'input' => [
|
||||||
|
[
|
||||||
|
'role' => 'developer',
|
||||||
|
'content' => [[
|
||||||
|
'type' => 'input_text',
|
||||||
|
'text' => $prompt,
|
||||||
|
]],
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
'content' => [
|
'content' => [
|
||||||
[
|
[
|
||||||
'type' => 'input_text',
|
'type' => 'input_text',
|
||||||
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
'text' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -82,17 +97,43 @@ class openai implements openai_i
|
|||||||
];
|
];
|
||||||
|
|
||||||
$response = $this->sendRequest($data);
|
$response = $this->sendRequest($data);
|
||||||
$output = $response['output'][0]['content'][0]['text'] ?? null;
|
return self::parseJsonTaskResponse($response);
|
||||||
if (!is_string($output) || $output === '') {
|
}
|
||||||
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
|
||||||
|
public static function parseJsonTaskResponse(array $response): array
|
||||||
|
{
|
||||||
|
$status = (string)($response['status'] ?? '');
|
||||||
|
if ($status === 'incomplete') {
|
||||||
|
$reason = preg_replace('/[^a-z0-9_.-]/i', '', (string)($response['incomplete_details']['reason'] ?? 'unknown')) ?: 'unknown';
|
||||||
|
throw new openai_request_exception('OpenAI response was incomplete: ' . $reason, true);
|
||||||
|
}
|
||||||
|
if ($status !== 'completed') {
|
||||||
|
throw new openai_request_exception('OpenAI response did not complete.', in_array($status, ['queued', 'in_progress'], true));
|
||||||
}
|
}
|
||||||
|
|
||||||
$decoded = json_decode($output, true);
|
$outputText = null;
|
||||||
|
foreach ((array)($response['output'] ?? []) as $output) {
|
||||||
|
foreach ((array)($output['content'] ?? []) as $content) {
|
||||||
|
if (($content['type'] ?? null) === 'refusal') {
|
||||||
|
throw new openai_request_exception('OpenAI refused the structured task.', false);
|
||||||
|
}
|
||||||
|
if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
|
||||||
|
$outputText = (string)$content['text'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($outputText === null || $outputText === '') {
|
||||||
|
throw new openai_request_exception('OpenAI completed without structured output text.', false);
|
||||||
|
}
|
||||||
|
$decoded = json_decode($outputText, true);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
throw new openai_request_exception('OpenAI returned invalid structured JSON.', false);
|
||||||
}
|
}
|
||||||
|
$resolvedModel = trim((string)($response['model'] ?? ''));
|
||||||
return $decoded;
|
if ($resolvedModel === '') {
|
||||||
|
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
|
||||||
|
}
|
||||||
|
return [...$decoded, '_openai_response_model' => $resolvedModel];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getLPRSchema(): array
|
protected function getLPRSchema(): array
|
||||||
@@ -296,14 +337,19 @@ class openai implements openai_i
|
|||||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
||||||
$response = curl_exec($curl);
|
$response = curl_exec($curl);
|
||||||
if (curl_errno($curl)) {
|
if (curl_errno($curl)) {
|
||||||
throw new Exception('cURL error: ' . curl_error($curl));
|
$curlCode = curl_errno($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
throw new openai_request_exception('OpenAI 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);
|
curl_close($curl);
|
||||||
$responseData = json_decode($response, true);
|
$responseData = json_decode($response, true);
|
||||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
throw new openai_request_exception('OpenAI returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||||
|
}
|
||||||
|
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
|
||||||
|
throw new openai_request_exception('OpenAI request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||||
}
|
}
|
||||||
//print_r($responseData);
|
|
||||||
return $responseData;
|
return $responseData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
namespace classes;
|
namespace classes;
|
||||||
|
|
||||||
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
||||||
|
require_once WD . '/classes/xlvask_automation_policy_service.php';
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use helpers\xlvask_usage_log;
|
use helpers\xlvask_usage_log;
|
||||||
@@ -32,9 +33,13 @@ class xlvask_automation_service
|
|||||||
private const AUTO_ATTACH_CONFIDENCE = 0.92;
|
private const AUTO_ATTACH_CONFIDENCE = 0.92;
|
||||||
private const AUTO_CREATE_CONFIDENCE = 0.97;
|
private const AUTO_CREATE_CONFIDENCE = 0.97;
|
||||||
private const CREATE_MIN_AGE_HOURS = 6.0;
|
private const CREATE_MIN_AGE_HOURS = 6.0;
|
||||||
private const OPENAI_CACHE_VERSION = 1;
|
private const OPENAI_CACHE_VERSION = 2;
|
||||||
public const POLICY_VERSION = 'xlvask-autopilot-v1';
|
public const POLICY_VERSION = 'xlvask-ai-auto-v2';
|
||||||
private const PLANNER_MODEL = 'gpt-5.6-sol';
|
private const PLANNER_MODEL = 'gpt-5.6-sol';
|
||||||
|
private const PLANNER_PROMPT_VERSION = 'xlvask-planner-da-v2';
|
||||||
|
private const PLANNER_SCHEMA_VERSION = 'xlvask-automation-schema-v2';
|
||||||
|
private const PLANNER_PROMPT = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.';
|
||||||
|
private const OPENAI_ATTACH_MAX_DISTANCE_HOURS = 6;
|
||||||
private ?int $runId = null;
|
private ?int $runId = null;
|
||||||
private bool $readOnlyEvaluation = false;
|
private bool $readOnlyEvaluation = false;
|
||||||
|
|
||||||
@@ -55,6 +60,11 @@ class xlvask_automation_service
|
|||||||
return $this;
|
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
|
public function evaluateUsageLogById(int $usageLogId, ?int $actorId = null, bool $allowExecute = true): array
|
||||||
{
|
{
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||||
@@ -194,6 +204,11 @@ class xlvask_automation_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->formatSuggestion($suggestionRow);
|
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) {
|
} catch (Exception $e) {
|
||||||
return [
|
return [
|
||||||
...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'),
|
...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'),
|
||||||
@@ -382,6 +397,9 @@ class xlvask_automation_service
|
|||||||
): array
|
): array
|
||||||
{
|
{
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||||
|
if ($runId !== null) {
|
||||||
|
$this->setRunContext($runId);
|
||||||
|
}
|
||||||
if ($actorId !== null && $allowedHallIds === []) {
|
if ($actorId !== null && $allowedHallIds === []) {
|
||||||
throw new Exception('No XL Vask hall scope is available for this user.');
|
throw new Exception('No XL Vask hall scope is available for this user.');
|
||||||
}
|
}
|
||||||
@@ -408,6 +426,14 @@ class xlvask_automation_service
|
|||||||
$this->updateUsageLogAutomationState((int)($row['id'] ?? 0), $result, $runId);
|
$this->updateUsageLogAutomationState((int)($row['id'] ?? 0), $result, $runId);
|
||||||
}
|
}
|
||||||
$results[] = ['usage_log_id' => (int)($row['id'] ?? 0), ...$result];
|
$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);
|
$status = (string)($result['status'] ?? self::STATUS_NONE);
|
||||||
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
||||||
if ($status === self::STATUS_AUTO_ACCEPTED) {
|
if ($status === self::STATUS_AUTO_ACCEPTED) {
|
||||||
@@ -464,6 +490,9 @@ class xlvask_automation_service
|
|||||||
self::STATUS_FAILED,
|
self::STATUS_FAILED,
|
||||||
]);
|
]);
|
||||||
$result = $suggestion === null ? $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')) : $this->formatSuggestion($suggestion);
|
$result = $suggestion === null ? $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')) : $this->formatSuggestion($suggestion);
|
||||||
|
if ($suggestion !== null) {
|
||||||
|
$result = [...$result, ...$this->readProjectionActionFlags($suggestion, $usageRow)];
|
||||||
|
}
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$result = $this->emptyAutomation((string)($usageRow['state_reason'] ?? ''));
|
$result = $this->emptyAutomation((string)($usageRow['state_reason'] ?? ''));
|
||||||
}
|
}
|
||||||
@@ -502,6 +531,35 @@ class xlvask_automation_service
|
|||||||
: 'uncertain';
|
: '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
|
public static function normalizeRegistrationForAutomation(string $registration): string
|
||||||
{
|
{
|
||||||
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? '');
|
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? '');
|
||||||
@@ -720,6 +778,7 @@ class xlvask_automation_service
|
|||||||
): string {
|
): string {
|
||||||
$input = [
|
$input = [
|
||||||
'version' => self::OPENAI_CACHE_VERSION,
|
'version' => self::OPENAI_CACHE_VERSION,
|
||||||
|
'planner_identity' => self::automationIdentityForAutomation(),
|
||||||
'schema_name' => $schemaName,
|
'schema_name' => $schemaName,
|
||||||
'prompt' => $prompt,
|
'prompt' => $prompt,
|
||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
@@ -730,6 +789,67 @@ class xlvask_automation_service
|
|||||||
return hash('sha256', self::stableJsonForAutomation($input));
|
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
|
public static function stableJsonForAutomation(mixed $value): string
|
||||||
{
|
{
|
||||||
$encoded = json_encode(
|
$encoded = json_encode(
|
||||||
@@ -839,55 +959,13 @@ class xlvask_automation_service
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$schemaName = 'xlvask_automation';
|
$schemaName = 'xlvask_automation';
|
||||||
$prompt = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.';
|
$prompt = self::PLANNER_PROMPT;
|
||||||
$temperature = 0.1;
|
$temperature = 0.1;
|
||||||
$schema = [
|
$schema = self::openAiPlannerSchemaForAutomation();
|
||||||
'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,
|
|
||||||
];
|
|
||||||
|
|
||||||
$creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS;
|
$creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS;
|
||||||
$payload = [
|
$payload = [
|
||||||
'usage_log' => [
|
'usage_log' => [
|
||||||
'opaque_context_id' => hash('sha256', (string)$context['signature_hash']),
|
|
||||||
'lane' => $context['signature']['lane'],
|
'lane' => $context['signature']['lane'],
|
||||||
'created_at' => $context['proposed_order']['created_at'] ?? null,
|
'created_at' => $context['proposed_order']['created_at'] ?? null,
|
||||||
'total_net_amount' => $context['total'],
|
'total_net_amount' => $context['total'],
|
||||||
@@ -920,6 +998,10 @@ class xlvask_automation_service
|
|||||||
|
|
||||||
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
||||||
$confidence = (float)($result['confidence'] ?? 0);
|
$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) {
|
if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -946,6 +1028,7 @@ class xlvask_automation_service
|
|||||||
'action' => $action,
|
'action' => $action,
|
||||||
'confidence' => min(1.0, max(0.0, $confidence)),
|
'confidence' => min(1.0, max(0.0, $confidence)),
|
||||||
'source' => self::SOURCE_OPENAI,
|
'source' => self::SOURCE_OPENAI,
|
||||||
|
'resolved_model' => $resolvedModel,
|
||||||
'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null,
|
'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null,
|
||||||
'created_order_id' => null,
|
'created_order_id' => null,
|
||||||
'candidate_order' => $candidate,
|
'candidate_order' => $candidate,
|
||||||
@@ -955,6 +1038,10 @@ class xlvask_automation_service
|
|||||||
'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))),
|
'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))),
|
||||||
'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')),
|
'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')),
|
||||||
];
|
];
|
||||||
|
} 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) {
|
} catch (Exception) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -967,21 +1054,36 @@ class xlvask_automation_service
|
|||||||
(string)($context['signature']['registration'] ?? ''),
|
(string)($context['signature']['registration'] ?? ''),
|
||||||
(string)($context['signature']['customer_number'] ?? ''),
|
(string)($context['signature']['customer_number'] ?? ''),
|
||||||
], static fn(string $value): bool => strlen($value) >= 3));
|
], static fn(string $value): bool => strlen($value) >= 3));
|
||||||
$reason = mb_substr((string)($result['reason_da'] ?? ''), 0, 1000);
|
$sanitize = static function (mixed $value, int $maxLength = 256) use ($redactions): string {
|
||||||
foreach ($redactions as $redaction) {
|
$value = mb_substr((string)$value, 0, $maxLength);
|
||||||
$reason = str_ireplace($redaction, '[redacted]', $reason);
|
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 [
|
return [
|
||||||
'action' => (string)($result['action'] ?? self::ACTION_NONE),
|
'action' => (string)($result['action'] ?? self::ACTION_NONE),
|
||||||
'confidence' => min(1.0, max(0.0, (float)($result['confidence'] ?? 0))),
|
'confidence' => min(1.0, max(0.0, (float)($result['confidence'] ?? 0))),
|
||||||
'reason_da' => $reason,
|
'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,
|
'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),
|
'proposed_order_items' => array_slice(array_values(array_filter((array)($result['proposed_order_items'] ?? []), 'is_array')), 0, 50),
|
||||||
'risk_flags' => array_slice(array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))), 0, 20),
|
'risk_flags' => $stringList($result['risk_flags'] ?? []),
|
||||||
'evidence' => array_slice(array_values(array_filter(array_map('strval', (array)($result['evidence'] ?? [])))), 0, 20),
|
'evidence' => $stringList($result['evidence'] ?? []),
|
||||||
'contradictions' => array_slice(array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))), 0, 20),
|
'contradictions' => $stringList($result['contradictions'] ?? []),
|
||||||
'plan_steps' => array_slice(array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')), 0, 20),
|
'plan_steps' => $planSteps,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,15 +1093,22 @@ class xlvask_automation_service
|
|||||||
$action = (string)$suggestion['action'];
|
$action = (string)$suggestion['action'];
|
||||||
$xlvask = new xlvask();
|
$xlvask = new xlvask();
|
||||||
|
|
||||||
|
if ((string)($suggestion['source'] ?? '') !== self::SOURCE_OPENAI) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ((string)($suggestion['certainty'] ?? '') !== 'certain'
|
if ((string)($suggestion['certainty'] ?? '') !== 'certain'
|
||||||
|| !$this->hardGuardsPassForCertainty($suggestion, $context)) {
|
|| !$this->hardGuardsPassForCertainty($suggestion, $context)
|
||||||
|
|| !(new xlvask_automation_policy_service())->policyAllowsActionReadOnly($action)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === self::ACTION_ATTACH) {
|
if ($action === self::ACTION_ATTACH) {
|
||||||
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
||||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
||||||
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
|
&& ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI
|
||||||
|
? $this->openAiAttachHardGuardsPass($suggestion, $context)
|
||||||
|
: $this->isExactAttachSuggestionForContext($suggestion, $context));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($action === self::ACTION_CREATE) {
|
if ($action === self::ACTION_CREATE) {
|
||||||
@@ -1014,14 +1123,16 @@ class xlvask_automation_service
|
|||||||
|
|
||||||
private function hardGuardsPassForCertainty(array $suggestion, array $context): bool
|
private function hardGuardsPassForCertainty(array $suggestion, array $context): bool
|
||||||
{
|
{
|
||||||
if ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI
|
if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()
|
||||||
|| !xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()
|
|
||||||
|| !self::sourceIsStableForAutomatic($context)) {
|
|| !self::sourceIsStableForAutomatic($context)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$action = (string)($suggestion['action'] ?? '');
|
$action = (string)($suggestion['action'] ?? '');
|
||||||
if ($action === self::ACTION_ATTACH) {
|
if ($action === self::ACTION_ATTACH) {
|
||||||
|
if ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI) {
|
||||||
|
return $this->openAiAttachHardGuardsPass($suggestion, $context);
|
||||||
|
}
|
||||||
return $this->isExactAttachSuggestionForContext($suggestion, $context);
|
return $this->isExactAttachSuggestionForContext($suggestion, $context);
|
||||||
}
|
}
|
||||||
if ($action === self::ACTION_CREATE) {
|
if ($action === self::ACTION_CREATE) {
|
||||||
@@ -1033,6 +1144,33 @@ class xlvask_automation_service
|
|||||||
return false;
|
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
|
private function decorateSuggestionWithEvidence(array $suggestion, array $context): array
|
||||||
{
|
{
|
||||||
$evidence = [[
|
$evidence = [[
|
||||||
@@ -1075,14 +1213,15 @@ class xlvask_automation_service
|
|||||||
if ($calibration === null) {
|
if ($calibration === null) {
|
||||||
$riskFlags[] = 'calibration_artifact_unavailable';
|
$riskFlags[] = 'calibration_artifact_unavailable';
|
||||||
}
|
}
|
||||||
if ((string)$suggestion['source'] === self::SOURCE_OPENAI) {
|
if ((string)$suggestion['source'] === self::SOURCE_OPENAI && $certainty !== 'certain') {
|
||||||
$riskFlags[] = 'ai_advisory_only';
|
$riskFlags[] = 'ai_not_calibrated_for_automatic_action';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...$suggestion,
|
...$suggestion,
|
||||||
'certainty' => $certainty,
|
'certainty' => $certainty,
|
||||||
'model' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? self::PLANNER_MODEL : null,
|
'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,
|
'model_confidence' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? (float)$suggestion['confidence'] : null,
|
||||||
'calibrated_probability' => $calibration === null ? null : (float)$calibration['calibrated_probability'],
|
'calibrated_probability' => $calibration === null ? null : (float)$calibration['calibrated_probability'],
|
||||||
'evidence' => $evidence,
|
'evidence' => $evidence,
|
||||||
@@ -1103,7 +1242,8 @@ class xlvask_automation_service
|
|||||||
$policy = $db->escape_string(self::POLICY_VERSION);
|
$policy = $db->escape_string(self::POLICY_VERSION);
|
||||||
$result = $db->query(
|
$result = $db->query(
|
||||||
"SELECT * FROM xlvask_automation_calibrations
|
"SELECT * FROM xlvask_automation_calibrations
|
||||||
WHERE policy_version = '{$policy}' AND segment_key = '{$segmentKey}' AND active = 1
|
WHERE policy_version = '{$policy}' AND segment_key = '{$segmentKey}'
|
||||||
|
AND active = 1 AND invalidated_at IS NULL
|
||||||
ORDER BY id DESC LIMIT 1"
|
ORDER BY id DESC LIMIT 1"
|
||||||
);
|
);
|
||||||
if ($result === false || $result->num_rows < 1) {
|
if ($result === false || $result->num_rows < 1) {
|
||||||
@@ -1115,6 +1255,10 @@ class xlvask_automation_service
|
|||||||
|| ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
|| ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
||||||
|| ($artifact['policy_version'] ?? null) !== self::POLICY_VERSION
|
|| ($artifact['policy_version'] ?? null) !== self::POLICY_VERSION
|
||||||
|| ($artifact['segment_key'] ?? null) !== $segmentKey
|
|| ($artifact['segment_key'] ?? null) !== $segmentKey
|
||||||
|
|| !hash_equals(
|
||||||
|
(string)($artifact['automation_identity_hash'] ?? ''),
|
||||||
|
(string)self::automationIdentityForAutomation()['identity_hash']
|
||||||
|
)
|
||||||
|| !hash_equals(
|
|| !hash_equals(
|
||||||
(string)($row['artifact_hash'] ?? ''),
|
(string)($row['artifact_hash'] ?? ''),
|
||||||
hash('sha256', self::stableJsonForAutomation($artifact))
|
hash('sha256', self::stableJsonForAutomation($artifact))
|
||||||
@@ -1181,11 +1325,27 @@ class xlvask_automation_service
|
|||||||
$connection->rollback();
|
$connection->rollback();
|
||||||
} catch (\Throwable) {
|
} 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);
|
$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 [
|
return [
|
||||||
...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion),
|
...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion),
|
||||||
'status' => self::STATUS_FAILED,
|
'status' => self::STATUS_FAILED,
|
||||||
'error' => $e->getMessage(),
|
'error' => 'The automatic XL Vask action failed closed.',
|
||||||
|
'critical_invariant' => $criticalInvariant,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1207,27 +1367,44 @@ class xlvask_automation_service
|
|||||||
|| !hash_equals((string)($lockedUsage['source_hash'] ?? ''), (string)($context['source_hash'] ?? ''))) {
|
|| !hash_equals((string)($lockedUsage['source_hash'] ?? ''), (string)($context['source_hash'] ?? ''))) {
|
||||||
throw new Exception('XL Vask-kildedata blev ændret efter evalueringen.');
|
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) {
|
if (!empty($lockedUsage['ignored_at']) || (int)($lockedUsage['FinishStatus'] ?? 0) !== 1) {
|
||||||
throw new Exception('XL Vask-vasken er ikke længere behandlingsklar.');
|
throw new Exception('XL Vask-vasken er ikke længere behandlingsklar.');
|
||||||
}
|
}
|
||||||
if ($automatic && !self::sourceIsStableForAutomatic($lockedUsage)) {
|
if ($automatic && !self::sourceIsStableForAutomatic($lockedUsage)) {
|
||||||
throw new Exception('XL Vask-kildedata mangler to observationer eller stabilitetsvinduet.');
|
throw new Exception('XL Vask-kildedata mangler to observationer eller stabilitetsvinduet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$washId = $db->escape_string((string)$context['wash_id']);
|
$washId = $db->escape_string((string)$context['wash_id']);
|
||||||
$duplicateResult = $db->query(
|
$duplicateResult = $db->query(
|
||||||
"SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{$washId}')) FOR UPDATE"
|
"SELECT id FROM orders
|
||||||
|
WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{$washId}')), '') FOR UPDATE"
|
||||||
);
|
);
|
||||||
if ($duplicateResult !== false && $duplicateResult->num_rows > 0) {
|
if ($duplicateResult !== false && $duplicateResult->num_rows > 0) {
|
||||||
throw new Exception('Vasken er allerede tilknyttet en ordre.');
|
throw new Exception('Vasken er allerede tilknyttet en ordre.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$action = (string)$suggestion['action'];
|
$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;
|
$matchedOrderId = null;
|
||||||
$createdOrderId = null;
|
$createdOrderId = null;
|
||||||
if ($action === self::ACTION_ATTACH) {
|
if ($action === self::ACTION_ATTACH) {
|
||||||
$orderId = (int)$suggestion['matched_order_id'];
|
$orderId = (int)$suggestion['matched_order_id'];
|
||||||
$allowedCandidateIds = array_map(static fn(array $candidate): int => (int)$candidate['id'], $context['candidate_orders']);
|
$allowedCandidateIds = array_map(static fn(array $candidate): int => (int)$candidate['id'], $currentCandidates);
|
||||||
if ($orderId < 1 || !in_array($orderId, $allowedCandidateIds, true)) {
|
if ($orderId < 1 || !in_array($orderId, $allowedCandidateIds, true)) {
|
||||||
throw new Exception('Ordren er ikke længere en tilladt kandidat.');
|
throw new Exception('Ordren er ikke længere en tilladt kandidat.');
|
||||||
}
|
}
|
||||||
@@ -1243,8 +1420,16 @@ class xlvask_automation_service
|
|||||||
throw new Exception('Ordren er ændret eller økonomisk låst.');
|
throw new Exception('Ordren er ændret eller økonomisk låst.');
|
||||||
}
|
}
|
||||||
$orderItems = (new orders_o())->getOrderItems($orderId);
|
$orderItems = (new orders_o())->getOrderItems($orderId);
|
||||||
if ($automatic && !self::isExactItemMatchForAutomation($context['items'], $orderItems)) {
|
if ($automatic) {
|
||||||
throw new Exception('Ordrelinjer eller beløb matcher ikke længere præcist.');
|
$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
|
if ($db->query("UPDATE orders SET wash_id = '{$washId}' WHERE id = {$orderId}") === false
|
||||||
|| $db->conn()->affected_rows !== 1) {
|
|| $db->conn()->affected_rows !== 1) {
|
||||||
@@ -1258,7 +1443,7 @@ class xlvask_automation_service
|
|||||||
|| empty($lockedUsage['source_observed_at'])) {
|
|| empty($lockedUsage['source_observed_at'])) {
|
||||||
throw new Exception('Vasken har ikke været stabil gennem observationsvinduet.');
|
throw new Exception('Vasken har ikke været stabil gennem observationsvinduet.');
|
||||||
}
|
}
|
||||||
if ($context['candidate_orders'] !== [] || $this->itemsTotal($context['items']) !== (int)$context['total']) {
|
if ($currentCandidates !== [] || $this->itemsTotal($context['items']) !== (int)$context['total']) {
|
||||||
throw new Exception('Ordreoprettelsen kan ikke afstemmes sikkert.');
|
throw new Exception('Ordreoprettelsen kan ikke afstemmes sikkert.');
|
||||||
}
|
}
|
||||||
$order = $this->createOrderFromContext($context);
|
$order = $this->createOrderFromContext($context);
|
||||||
@@ -1297,6 +1482,17 @@ class xlvask_automation_service
|
|||||||
return $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
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
|
private function automaticFeedbackReason(array $suggestion, array $context): string
|
||||||
{
|
{
|
||||||
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
||||||
@@ -1419,6 +1615,7 @@ class xlvask_automation_service
|
|||||||
return [
|
return [
|
||||||
'usage_log_id' => $usageLogId,
|
'usage_log_id' => $usageLogId,
|
||||||
'wash_id' => (string)$log->WashId,
|
'wash_id' => (string)$log->WashId,
|
||||||
|
'hall_id' => trim((string)$log->HallId),
|
||||||
'log' => $log,
|
'log' => $log,
|
||||||
'proposed_order' => $proposedOrder,
|
'proposed_order' => $proposedOrder,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
@@ -1478,7 +1675,7 @@ class xlvask_automation_service
|
|||||||
return self::scoreItemMatchForAutomation($usageItems, $orderItems);
|
return self::scoreItemMatchForAutomation($usageItems, $orderItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array
|
private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder, bool $forUpdate = false): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
@@ -1510,9 +1707,15 @@ class xlvask_automation_service
|
|||||||
OR REPLACE(UPPER(reg_3), ' ', '') 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
|
ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC
|
||||||
LIMIT 20";
|
LIMIT 20" . ($forUpdate ? ' FOR UPDATE' : '');
|
||||||
|
|
||||||
$rows = $db->fetch_all($db->query($sql));
|
$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 {
|
return array_map(function (array $row): array {
|
||||||
$orderItems = (new orders_o())->getOrderItems((int)$row['id']);
|
$orderItems = (new orders_o())->getOrderItems((int)$row['id']);
|
||||||
return [
|
return [
|
||||||
@@ -1608,8 +1811,16 @@ class xlvask_automation_service
|
|||||||
|
|
||||||
$existing = $this->latestActionableSuggestion((int)$context['usage_log_id']);
|
$existing = $this->latestActionableSuggestion((int)$context['usage_log_id']);
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
$this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId);
|
if ((string)($existing['source'] ?? '') === self::SOURCE_OPENAI
|
||||||
return (int)$existing['id'];
|
|| (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 = [
|
$fields = [
|
||||||
@@ -1623,6 +1834,7 @@ class xlvask_automation_service
|
|||||||
'confidence' => (float)$suggestion['confidence'],
|
'confidence' => (float)$suggestion['confidence'],
|
||||||
'source' => (string)$suggestion['source'],
|
'source' => (string)$suggestion['source'],
|
||||||
'policy_version' => self::POLICY_VERSION,
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
'planner_identity_hash' => (string)($suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash']),
|
||||||
'model' => $suggestion['model'] ?? null,
|
'model' => $suggestion['model'] ?? null,
|
||||||
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
||||||
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
||||||
@@ -1678,6 +1890,7 @@ class xlvask_automation_service
|
|||||||
'confidence' => (float)$suggestion['confidence'],
|
'confidence' => (float)$suggestion['confidence'],
|
||||||
'source' => (string)$suggestion['source'],
|
'source' => (string)$suggestion['source'],
|
||||||
'policy_version' => self::POLICY_VERSION,
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
'planner_identity_hash' => (string)($suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash']),
|
||||||
'model' => $suggestion['model'] ?? null,
|
'model' => $suggestion['model'] ?? null,
|
||||||
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
||||||
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
||||||
@@ -2203,10 +2416,23 @@ class xlvask_automation_service
|
|||||||
private function formatSuggestion(array $row): array
|
private function formatSuggestion(array $row): array
|
||||||
{
|
{
|
||||||
$status = (string)($row['status'] ?? self::STATUS_NONE);
|
$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 [
|
return [
|
||||||
'id' => isset($row['id']) ? (int)$row['id'] : null,
|
'id' => isset($row['id']) ? (int)$row['id'] : null,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'action' => (string)($row['action'] ?? self::ACTION_NONE),
|
'action' => $action,
|
||||||
'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0,
|
'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0,
|
||||||
'source' => (string)($row['source'] ?? ''),
|
'source' => (string)($row['source'] ?? ''),
|
||||||
'certainty' => (string)($row['certainty'] ?? 'uncertain'),
|
'certainty' => (string)($row['certainty'] ?? 'uncertain'),
|
||||||
@@ -2216,6 +2442,7 @@ class xlvask_automation_service
|
|||||||
'model_confidence' => isset($row['model_confidence']) && $row['model_confidence'] !== null
|
'model_confidence' => isset($row['model_confidence']) && $row['model_confidence'] !== null
|
||||||
? (float)$row['model_confidence'] : null,
|
? (float)$row['model_confidence'] : null,
|
||||||
'policy_version' => (string)($row['policy_version'] ?? self::POLICY_VERSION),
|
'policy_version' => (string)($row['policy_version'] ?? self::POLICY_VERSION),
|
||||||
|
'planner_identity_hash' => $row['planner_identity_hash'] ?? null,
|
||||||
'evidence' => $this->decodeJsonField($row['evidence_json'] ?? null) ?? [],
|
'evidence' => $this->decodeJsonField($row['evidence_json'] ?? null) ?? [],
|
||||||
'contradictions' => $this->decodeJsonField($row['contradictions_json'] ?? null) ?? [],
|
'contradictions' => $this->decodeJsonField($row['contradictions_json'] ?? null) ?? [],
|
||||||
'risk_flags' => $this->decodeJsonField($row['risk_flags_json'] ?? null) ?? [],
|
'risk_flags' => $this->decodeJsonField($row['risk_flags_json'] ?? null) ?? [],
|
||||||
@@ -2225,10 +2452,21 @@ class xlvask_automation_service
|
|||||||
'reason' => (string)($row['reason'] ?? ''),
|
'reason' => (string)($row['reason'] ?? ''),
|
||||||
'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null,
|
'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,
|
'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null,
|
||||||
'candidate_order' => $this->decodeJsonField($row['candidate_order_json'] ?? null),
|
'candidate_order' => $candidateOrder,
|
||||||
'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null),
|
'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null),
|
||||||
'can_accept' => $status === self::STATUS_SUGGESTED,
|
'can_accept' => $suggestedAction,
|
||||||
'can_deny' => $status === self::STATUS_SUGGESTED,
|
'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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2246,6 +2484,7 @@ class xlvask_automation_service
|
|||||||
'model' => $suggestion['model'] ?? null,
|
'model' => $suggestion['model'] ?? null,
|
||||||
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
||||||
'policy_version' => self::POLICY_VERSION,
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
'planner_identity_hash' => $suggestion['planner_identity_hash'] ?? self::automationIdentityForAutomation()['identity_hash'],
|
||||||
'reason' => (string)($suggestion['reason'] ?? ''),
|
'reason' => (string)($suggestion['reason'] ?? ''),
|
||||||
'matched_order_id' => $suggestion['matched_order_id'] ?? null,
|
'matched_order_id' => $suggestion['matched_order_id'] ?? null,
|
||||||
'created_order_id' => null,
|
'created_order_id' => null,
|
||||||
@@ -2259,6 +2498,15 @@ class xlvask_automation_service
|
|||||||
'run_id' => $this->runId,
|
'run_id' => $this->runId,
|
||||||
'can_accept' => false,
|
'can_accept' => false,
|
||||||
'can_deny' => 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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2275,6 +2523,7 @@ class xlvask_automation_service
|
|||||||
'model' => null,
|
'model' => null,
|
||||||
'model_confidence' => null,
|
'model_confidence' => null,
|
||||||
'policy_version' => self::POLICY_VERSION,
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
'planner_identity_hash' => null,
|
||||||
'evidence' => [],
|
'evidence' => [],
|
||||||
'contradictions' => [],
|
'contradictions' => [],
|
||||||
'risk_flags' => [],
|
'risk_flags' => [],
|
||||||
@@ -2288,6 +2537,35 @@ class xlvask_automation_service
|
|||||||
'proposed_order' => null,
|
'proposed_order' => null,
|
||||||
'can_accept' => false,
|
'can_accept' => false,
|
||||||
'can_deny' => 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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2301,6 +2579,32 @@ class xlvask_automation_service
|
|||||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
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
|
private function itemSignatureParts(array $items): array
|
||||||
{
|
{
|
||||||
return self::itemSignaturePartsForAutomation($items);
|
return self::itemSignaturePartsForAutomation($items);
|
||||||
@@ -2310,7 +2614,6 @@ class xlvask_automation_service
|
|||||||
{
|
{
|
||||||
return array_map(fn(array $item): array => [
|
return array_map(fn(array $item): array => [
|
||||||
'product_id' => (int)($item['product_id'] ?? 0),
|
'product_id' => (int)($item['product_id'] ?? 0),
|
||||||
'product_name' => (string)($item['product']['name'] ?? $item['product_name'] ?? ''),
|
|
||||||
'quantity' => (int)($item['quantity'] ?? 0),
|
'quantity' => (int)($item['quantity'] ?? 0),
|
||||||
'price' => (int)($item['price'] ?? 0),
|
'price' => (int)($item['price'] ?? 0),
|
||||||
], $items);
|
], $items);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use Throwable;
|
|||||||
*/
|
*/
|
||||||
class xlvask_autopilot_service
|
class xlvask_autopilot_service
|
||||||
{
|
{
|
||||||
public const POLICY_VERSION = 'xlvask-autopilot-v1';
|
public const POLICY_VERSION = xlvask_automation_service::POLICY_VERSION;
|
||||||
private const PREVIEW_TTL_SECONDS = 900;
|
private const PREVIEW_TTL_SECONDS = 900;
|
||||||
|
|
||||||
public static function modeCapabilities(string $mode): array
|
public static function modeCapabilities(string $mode): array
|
||||||
@@ -58,12 +58,31 @@ class xlvask_autopilot_service
|
|||||||
&& hash_equals($sourceHash, (string)($current['source_hash'] ?? ''));
|
&& 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
|
public function createRun(array $input, ?int $actorId = null, array $allowedHallIds = []): array
|
||||||
{
|
{
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
$mode = strtolower(trim((string)($input['mode'] ?? 'execute')));
|
$mode = strtolower(trim((string)($input['mode'] ?? '')));
|
||||||
|
if ($mode === '') {
|
||||||
|
throw new Exception('An explicit XL Vask autopilot mode is required.');
|
||||||
|
}
|
||||||
self::modeCapabilities($mode);
|
self::modeCapabilities($mode);
|
||||||
$dateFrom = $this->normalizeDate($input['dateFrom'] ?? null, 'dateFrom');
|
$dateFrom = $this->normalizeDate($input['dateFrom'] ?? null, 'dateFrom');
|
||||||
$dateTo = $this->normalizeDate($input['dateTo'] ?? null, 'dateTo');
|
$dateTo = $this->normalizeDate($input['dateTo'] ?? null, 'dateTo');
|
||||||
@@ -91,6 +110,19 @@ class xlvask_autopilot_service
|
|||||||
if ($allowedHallIds === []) {
|
if ($allowedHallIds === []) {
|
||||||
throw new Exception('No XL Vask hall scope is available for this user.');
|
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.
|
// An explicit retry key is stable; otherwise every requested rerun is a new durable run.
|
||||||
$providedKey = trim((string)($input['idempotency_key'] ?? ''));
|
$providedKey = trim((string)($input['idempotency_key'] ?? ''));
|
||||||
$key = self::idempotencyKey($providedKey, $actorId, $this->uuidV4());
|
$key = self::idempotencyKey($providedKey, $actorId, $this->uuidV4());
|
||||||
@@ -111,18 +143,20 @@ class xlvask_autopilot_service
|
|||||||
$keySql = $db->escape_string($key);
|
$keySql = $db->escape_string($key);
|
||||||
$modeSql = $db->escape_string($mode);
|
$modeSql = $db->escape_string($mode);
|
||||||
|
|
||||||
$db->query(
|
if ($db->query(
|
||||||
"INSERT INTO xlvask_autopilot_runs
|
"INSERT INTO xlvask_autopilot_runs
|
||||||
(idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json,
|
(idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json,
|
||||||
requested_limit, scope_hall_ids_json, created_by)
|
requested_limit, scope_hall_ids_json, created_by)
|
||||||
VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ",
|
VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ",
|
||||||
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql})
|
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql})
|
||||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
|
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();
|
$runId = (int)$db->insert_id();
|
||||||
$existing = $db->fetch_assoc($db->query("SELECT request_hash FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1"));
|
$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'] ?? ''))) {
|
if (!hash_equals($requestHash, (string)($existing['request_hash'] ?? ''))) {
|
||||||
throw new Exception('XL Vask idempotency key was already used with a different request payload.');
|
throw new Exception('An active execute run exists or the idempotency key belongs to a different request.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getRun($runId, $actorId, $allowedHallIds);
|
return $this->getRun($runId, $actorId, $allowedHallIds);
|
||||||
@@ -374,7 +408,8 @@ class xlvask_autopilot_service
|
|||||||
"SELECT id, policy_version, segment_key, precision_value, wilson_lower_bound,
|
"SELECT id, policy_version, segment_key, precision_value, wilson_lower_bound,
|
||||||
holdout_examples, segment_examples, contradictions, calibrated_probability,
|
holdout_examples, segment_examples, contradictions, calibrated_probability,
|
||||||
artifact_hash, backtest_json, activated_at
|
artifact_hash, backtest_json, activated_at
|
||||||
FROM xlvask_automation_calibrations WHERE active = 1 ORDER BY segment_key"
|
FROM xlvask_automation_calibrations
|
||||||
|
WHERE active = 1 AND invalidated_at IS NULL ORDER BY segment_key"
|
||||||
);
|
);
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$verified = [];
|
$verified = [];
|
||||||
@@ -384,6 +419,14 @@ class xlvask_autopilot_service
|
|||||||
|| ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
|| ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
||||||
|| ($artifact['policy_version'] ?? null) !== (string)$row['policy_version']
|
|| ($artifact['policy_version'] ?? null) !== (string)$row['policy_version']
|
||||||
|| ($artifact['segment_key'] ?? null) !== (string)$row['segment_key']
|
|| ($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(
|
|| !hash_equals(
|
||||||
(string)($row['artifact_hash'] ?? ''),
|
(string)($row['artifact_hash'] ?? ''),
|
||||||
hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact))
|
hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact))
|
||||||
@@ -406,36 +449,118 @@ class xlvask_autopilot_service
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
// Readiness GET is intentionally read-only and fails closed when schema is not ready.
|
// Readiness GET is intentionally read-only and fails closed when schema is not ready.
|
||||||
}
|
}
|
||||||
|
$policyReadiness = (new xlvask_automation_policy_service())->readinessReadOnly();
|
||||||
return [
|
return [
|
||||||
|
...$policyReadiness,
|
||||||
'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(),
|
'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(),
|
||||||
'active_calibrations' => $active,
|
'active_calibrations' => $active,
|
||||||
'automatic_actions_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady() && $active !== [],
|
'automatic_actions_ready' => (bool)($policyReadiness['ready'] ?? false),
|
||||||
'wash_id_activation_phrase' => 'ACTIVATE-WASH-ID-UNIQUENESS',
|
'wash_id_activation_phrase' => 'ACTIVATE-WASH-ID-UNIQUENESS',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function adjudicateCalibrationLabel(int $suggestionId, string $outcome, ?int $actorId): array
|
public function adjudicateCalibrationLabel(
|
||||||
|
int $suggestionId,
|
||||||
|
string $outcome,
|
||||||
|
?int $actorId,
|
||||||
|
array $allowedHallIds = []
|
||||||
|
): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||||
if ($suggestionId < 1 || !in_array($outcome, ['correct', 'incorrect'], true) || $actorId === null) {
|
if ($suggestionId < 1 || !in_array($outcome, ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'], true) || $actorId === null) {
|
||||||
throw new Exception('Invalid XL Vask calibration adjudication.');
|
throw new Exception('Invalid XL Vask calibration adjudication.');
|
||||||
}
|
}
|
||||||
$suggestion = $db->query(
|
$allowedHallIds = self::normalizeHallScope($allowedHallIds);
|
||||||
"SELECT id, policy_version, source, action FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"
|
if ($allowedHallIds === []) {
|
||||||
);
|
throw new Exception('No XL Vask hall scope is available for calibration adjudication.');
|
||||||
if ($suggestion === false || $suggestion->num_rows < 1) {
|
|
||||||
throw new Exception('XL Vask suggestion not found for calibration adjudication.');
|
|
||||||
}
|
}
|
||||||
$outcomeSql = $db->escape_string($outcome);
|
$hallSql = implode(',', array_map(
|
||||||
if ($db->query(
|
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
|
||||||
"INSERT INTO xlvask_automation_calibration_label_events
|
$allowedHallIds
|
||||||
(suggestion_id, outcome, adjudicated_by, adjudicated_at)
|
));
|
||||||
VALUES ({$suggestionId}, '{$outcomeSql}', {$actorId}, NOW())"
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
||||||
) === false) {
|
$policySql = $db->escape_string((string)$identity['policy_version']);
|
||||||
throw new Exception('XL Vask calibration adjudication could not be stored.');
|
$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, 'adjudicated' => true];
|
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. */
|
/** Generate an inactive, PII-free artifact from exact admin-adjudicated suggestion labels. */
|
||||||
@@ -443,31 +568,44 @@ class xlvask_autopilot_service
|
|||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||||
if (!preg_match('/^(deterministic|fuzzy|history):(attach_order|create_order)$/', $segmentKey)) {
|
if (!preg_match('/^(deterministic|fuzzy|history|openai):(attach_order|create_order)$/', $segmentKey)) {
|
||||||
throw new Exception('Invalid XL Vask calibration segment.');
|
throw new Exception('Invalid XL Vask calibration segment.');
|
||||||
}
|
}
|
||||||
[$source, $action] = explode(':', $segmentKey, 2);
|
[$source, $action] = explode(':', $segmentKey, 2);
|
||||||
$sourceSql = $db->escape_string($source);
|
$sourceSql = $db->escape_string($source);
|
||||||
$actionSql = $db->escape_string($action);
|
$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(
|
$labels = $db->fetch_all($db->query(
|
||||||
"SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at,
|
"SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at,
|
||||||
s.policy_version, s.source, s.action, s.created_at AS suggestion_created_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
|
FROM xlvask_automation_calibration_label_events l
|
||||||
LEFT JOIN xlvask_automation_calibration_label_events newer
|
LEFT JOIN xlvask_automation_calibration_label_events newer
|
||||||
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
|
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
|
||||||
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
|
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
|
||||||
WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}'
|
WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}'
|
||||||
AND s.policy_version = '" . self::POLICY_VERSION . "'
|
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
|
AND newer.id IS NULL
|
||||||
ORDER BY s.created_at ASC, s.id ASC, l.id ASC"
|
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(
|
$overallRow = $db->fetch_assoc($db->query(
|
||||||
"SELECT COUNT(*) AS total
|
"SELECT COUNT(*) AS total
|
||||||
FROM xlvask_automation_calibration_label_events l
|
FROM xlvask_automation_calibration_label_events l
|
||||||
LEFT JOIN xlvask_automation_calibration_label_events newer
|
LEFT JOIN xlvask_automation_calibration_label_events newer
|
||||||
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
|
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
|
||||||
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
|
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
|
||||||
WHERE s.policy_version = '" . self::POLICY_VERSION . "' AND newer.id IS NULL"
|
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);
|
$overallExamples = (int)($overallRow['total'] ?? 0);
|
||||||
$total = count($labels);
|
$total = count($labels);
|
||||||
@@ -486,12 +624,18 @@ class xlvask_autopilot_service
|
|||||||
'adjudicated_at' => (string)$label['adjudicated_at'],
|
'adjudicated_at' => (string)$label['adjudicated_at'],
|
||||||
'suggestion_created_at' => (string)$label['suggestion_created_at'],
|
'suggestion_created_at' => (string)$label['suggestion_created_at'],
|
||||||
'policy_version' => (string)$label['policy_version'],
|
'policy_version' => (string)$label['policy_version'],
|
||||||
|
'planner_identity_hash' => (string)$label['planner_identity_hash'],
|
||||||
|
'resolved_model' => (string)$label['model'],
|
||||||
'source' => (string)$label['source'],
|
'source' => (string)$label['source'],
|
||||||
'action' => (string)$label['action'],
|
'action' => (string)$label['action'],
|
||||||
], $labels);
|
], $labels);
|
||||||
$artifact = [
|
$artifact = [
|
||||||
'policy_version' => self::POLICY_VERSION,
|
'policy_version' => self::POLICY_VERSION,
|
||||||
|
'automation_identity' => $identity,
|
||||||
|
'automation_identity_hash' => (string)$identity['identity_hash'],
|
||||||
|
'resolved_model' => (string)$identity['model'],
|
||||||
'segment_key' => $segmentKey,
|
'segment_key' => $segmentKey,
|
||||||
|
'safety_epoch' => $this->calibrationSafetyEpoch($segmentKey),
|
||||||
'split_rule' => 'chronological_80_20_by_suggestion_created_at_and_id',
|
'split_rule' => 'chronological_80_20_by_suggestion_created_at_and_id',
|
||||||
'training_examples' => $trainingExamples,
|
'training_examples' => $trainingExamples,
|
||||||
'holdout_examples' => $holdoutExamples,
|
'holdout_examples' => $holdoutExamples,
|
||||||
@@ -509,16 +653,23 @@ class xlvask_autopilot_service
|
|||||||
$artifactHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact));
|
$artifactHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact));
|
||||||
$artifactJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($artifact));
|
$artifactJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($artifact));
|
||||||
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
|
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
|
||||||
$db->query(
|
$inserted = $db->query(
|
||||||
"INSERT INTO xlvask_automation_calibrations
|
"INSERT INTO xlvask_automation_calibrations
|
||||||
(policy_version, segment_key, precision_value, wilson_lower_bound, holdout_examples,
|
(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)
|
segment_examples, contradictions, calibrated_probability, artifact_hash, active, backtest_json, created_by)
|
||||||
VALUES ('" . self::POLICY_VERSION . "', '" . $db->escape_string($segmentKey) . "', " . round($precision, 6) . ",
|
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) . ",
|
" . round($wilson, 6) . ", {$holdoutExamples}, {$total}, {$contradictions}, " . round($precision, 6) . ",
|
||||||
'{$artifactHash}', 0, '{$artifactJson}', {$actorSql})
|
'{$artifactHash}', 0, '{$artifactJson}', {$actorSql})
|
||||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
|
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();
|
$id = (int)$db->insert_id();
|
||||||
|
if ($id < 1) {
|
||||||
|
throw new Exception('The XL Vask calibration artifact identifier is unavailable.');
|
||||||
|
}
|
||||||
$qualifies = xlvask_automation_service::classifyCertaintyForAutomation([
|
$qualifies = xlvask_automation_service::classifyCertaintyForAutomation([
|
||||||
...$artifact,
|
...$artifact,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
@@ -549,14 +700,33 @@ class xlvask_autopilot_service
|
|||||||
if ($row === null || !hash_equals((string)$row['artifact_hash'], $artifactHash)) {
|
if ($row === null || !hash_equals((string)$row['artifact_hash'], $artifactHash)) {
|
||||||
throw new Exception('XL Vask calibration artifact not found or changed.');
|
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);
|
$backtest = json_decode((string)($row['backtest_json'] ?? ''), true);
|
||||||
if (!is_array($backtest)
|
if (!is_array($backtest)
|
||||||
|| ($backtest['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
|| ($backtest['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|
||||||
|| ($backtest['policy_version'] ?? null) !== (string)$row['policy_version']
|
|| ($backtest['policy_version'] ?? null) !== (string)$row['policy_version']
|
||||||
|| ($backtest['segment_key'] ?? null) !== (string)$row['segment_key']
|
|| ($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)))) {
|
|| !hash_equals($artifactHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($backtest)))) {
|
||||||
throw new Exception('XL Vask calibration artifact payload failed integrity verification.');
|
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') {
|
if (xlvask_automation_service::classifyCertaintyForAutomation([...$backtest, 'active' => true]) !== 'certain') {
|
||||||
throw new Exception('XL Vask calibration artifact does not meet the activation thresholds.');
|
throw new Exception('XL Vask calibration artifact does not meet the activation thresholds.');
|
||||||
}
|
}
|
||||||
@@ -581,6 +751,63 @@ class xlvask_autopilot_service
|
|||||||
return ['id' => $id, 'active' => true, 'segment_key' => (string)$row['segment_key']];
|
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
|
public function activateWashIdUniqueness(string $confirmationText): array
|
||||||
{
|
{
|
||||||
if (!hash_equals('ACTIVATE-WASH-ID-UNIQUENESS', trim($confirmationText))) {
|
if (!hash_equals('ACTIVATE-WASH-ID-UNIQUENESS', trim($confirmationText))) {
|
||||||
@@ -766,6 +993,12 @@ class xlvask_autopilot_service
|
|||||||
) === false || $db->conn()->affected_rows !== 1) {
|
) === false || $db->conn()->affected_rows !== 1) {
|
||||||
throw new Exception('The XL Vask ignore decision could not be applied atomically.');
|
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'];
|
$results[] = ['usage_log_id' => $usageId, 'resolution_state' => 'ignored'];
|
||||||
} else {
|
} else {
|
||||||
$results[] = $automation->applyBoundDecisionWithinTransaction(
|
$results[] = $automation->applyBoundDecisionWithinTransaction(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace classes;
|
|||||||
*/
|
*/
|
||||||
class xlvask_usage_logs_schema_bootstrap
|
class xlvask_usage_logs_schema_bootstrap
|
||||||
{
|
{
|
||||||
|
public const MIGRATION_VERSION = '20260804_xlvask_ai_auto_policy_v2';
|
||||||
private static bool $initialized = false;
|
private static bool $initialized = false;
|
||||||
|
|
||||||
public static function ensureTables(): void
|
public static function ensureTables(): void
|
||||||
@@ -14,15 +15,34 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
if (self::$initialized) {
|
if (self::$initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$status = self::migrationStatus();
|
||||||
|
if (!$status['ready']) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'XL Vask automation schema is not ready. Apply migration ' . self::MIGRATION_VERSION . ' explicitly.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit operator-invoked migration entrypoint. Request handlers and workers must never call this method.
|
||||||
|
*/
|
||||||
|
public static function applyExplicitMigration(): array
|
||||||
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|
||||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||||
return;
|
throw new \RuntimeException('The database connection is unavailable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
||||||
return;
|
throw new \RuntimeException('The xlvask_usage_logs table is unavailable.');
|
||||||
|
}
|
||||||
|
$conflicts = self::activeExecuteRunConflicts($db);
|
||||||
|
if ($conflicts !== []) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'XL Vask automation migration is blocked by existing active execute runs: ' . implode(', ', $conflicts)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
||||||
@@ -46,7 +66,129 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
|
||||||
self::ensureAutomationTables($db);
|
self::ensureAutomationTables($db);
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = false;
|
||||||
|
$status = self::migrationStatus();
|
||||||
|
if (!$status['ready']) {
|
||||||
|
throw new \RuntimeException('XL Vask automation migration did not reach a ready state.');
|
||||||
|
}
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only preflight used by readiness endpoints and normal request/worker entrypoints. */
|
||||||
|
public static function migrationStatus(): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$missingTables = [];
|
||||||
|
$missingColumns = [];
|
||||||
|
$requiredIndexes = [
|
||||||
|
'xlvask_autopilot_runs.uniq_xlvask_active_execute_run',
|
||||||
|
'xlvask_autopilot_runs.uniq_xlvask_autopilot_run_idempotency',
|
||||||
|
'xlvask_automation_action_events.uniq_xlvask_action_event_suggestion',
|
||||||
|
];
|
||||||
|
$missingIndexes = [];
|
||||||
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||||
|
return [
|
||||||
|
'version' => self::MIGRATION_VERSION,
|
||||||
|
'ready' => false,
|
||||||
|
'missing_tables' => ['database'],
|
||||||
|
'missing_columns' => [],
|
||||||
|
'required_indexes' => $requiredIndexes,
|
||||||
|
'missing_indexes' => $requiredIndexes,
|
||||||
|
'preflight_conflicts' => ['database_unavailable'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'xlvask_usage_logs', 'xlvask_automation_suggestions', 'xlvask_automation_feedback',
|
||||||
|
'xlvask_automation_openai_cache', 'xlvask_autopilot_runs', 'xlvask_autopilot_run_items',
|
||||||
|
'xlvask_automation_audit', 'xlvask_automation_calibrations',
|
||||||
|
'xlvask_automation_calibration_label_events', 'xlvask_automation_decision_previews',
|
||||||
|
'xlvask_automation_policy_state', 'xlvask_automation_policy_previews',
|
||||||
|
'xlvask_automation_policy_events', 'xlvask_automation_action_events',
|
||||||
|
] as $table) {
|
||||||
|
if (!self::tableExists($db, $table)) {
|
||||||
|
$missingTables[] = $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiredColumns = [
|
||||||
|
'xlvask_usage_logs' => [
|
||||||
|
'ignored_at', 'ignored_by', 'ignored_reason', 'cached_total_net_amount',
|
||||||
|
'cached_primary_product_name', 'cached_amount_at', 'source_hash', 'source_revision',
|
||||||
|
'source_observed_at', 'source_stable_since', 'source_observation_count', 'import_state',
|
||||||
|
'resolution_state', 'certainty', 'planned_action', 'state_reason', 'expected_version',
|
||||||
|
'last_run_id', 'last_evaluated_at',
|
||||||
|
],
|
||||||
|
'xlvask_automation_suggestions' => [
|
||||||
|
'run_id', 'policy_version', 'planner_identity_hash', 'model', 'model_confidence',
|
||||||
|
'calibrated_probability', 'certainty', 'evidence_json', 'contradictions_json',
|
||||||
|
'risk_flags_json', 'plan_steps_json', 'expected_version', 'input_hash',
|
||||||
|
],
|
||||||
|
'xlvask_autopilot_runs' => [
|
||||||
|
'idempotency_key', 'mode', 'status', 'phase', 'date_from', 'date_to', 'force_refetch',
|
||||||
|
'requested_ids_json', 'requested_limit', 'request_hash', 'scope_hall_ids_json',
|
||||||
|
'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token',
|
||||||
|
'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by',
|
||||||
|
'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot',
|
||||||
|
],
|
||||||
|
'xlvask_autopilot_run_items' => [
|
||||||
|
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
|
||||||
|
'planned_action', 'source_hash', 'expected_version', 'result_json', 'error', 'created_at', 'updated_at',
|
||||||
|
],
|
||||||
|
'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',
|
||||||
|
'activated_by', 'activated_at', 'invalidated_at', 'created_at',
|
||||||
|
],
|
||||||
|
'xlvask_automation_calibration_label_events' => [
|
||||||
|
'suggestion_id', 'outcome', 'adjudication_outcome', 'adjudicated_by',
|
||||||
|
'adjudicated_at', 'legacy_label_id',
|
||||||
|
],
|
||||||
|
'xlvask_automation_policy_state' => [
|
||||||
|
'policy_version', 'planner_identity_hash', 'stage', 'halted', 'attach_enabled',
|
||||||
|
'create_enabled', 'halt_reason', 'attach_halt_reason', 'create_halt_reason',
|
||||||
|
'halted_at', 'halted_by', 'attach_activated_at', 'attach_activated_by',
|
||||||
|
'create_activated_at', 'create_activated_by', 'expected_version', 'created_at', 'updated_at',
|
||||||
|
],
|
||||||
|
'xlvask_automation_policy_previews' => [
|
||||||
|
'selection_hash', 'requested_transition', 'payload_json', 'created_by',
|
||||||
|
'expires_at', 'applied_at', 'created_at',
|
||||||
|
],
|
||||||
|
'xlvask_automation_policy_events' => ['event_type', 'actor_id', 'details_json', 'created_at'],
|
||||||
|
'xlvask_automation_action_events' => [
|
||||||
|
'suggestion_id', 'run_id', 'hall_id', 'action', 'source', 'policy_version',
|
||||||
|
'planner_identity_hash', 'review_outcome', 'reviewed_by', 'reviewed_at', 'created_at',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
foreach ($requiredColumns as $table => $columns) {
|
||||||
|
if (!self::tableExists($db, $table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
if (!self::columnExists($db, $table, $column)) {
|
||||||
|
$missingColumns[] = $table . '.' . $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requiredIndexes as $requiredIndex) {
|
||||||
|
[$table, $index] = explode('.', $requiredIndex, 2);
|
||||||
|
if (!self::indexExists($db, $table, $index)) {
|
||||||
|
$missingIndexes[] = $requiredIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$conflicts = self::activeExecuteRunConflicts($db);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => self::MIGRATION_VERSION,
|
||||||
|
'ready' => $missingTables === [] && $missingColumns === [] && $missingIndexes === [] && $conflicts === [],
|
||||||
|
'missing_tables' => $missingTables,
|
||||||
|
'missing_columns' => $missingColumns,
|
||||||
|
'required_indexes' => $requiredIndexes,
|
||||||
|
'missing_indexes' => $missingIndexes,
|
||||||
|
'preflight_conflicts' => $conflicts,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function ensureAutomationTables(object $db): void
|
private static function ensureAutomationTables(object $db): void
|
||||||
@@ -84,7 +226,8 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
foreach ([
|
foreach ([
|
||||||
'run_id' => 'BIGINT NULL AFTER usage_log_id',
|
'run_id' => 'BIGINT NULL AFTER usage_log_id',
|
||||||
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
|
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
|
||||||
'model' => 'VARCHAR(96) NULL AFTER policy_version',
|
'planner_identity_hash' => 'CHAR(64) NULL AFTER policy_version',
|
||||||
|
'model' => 'VARCHAR(96) NULL AFTER planner_identity_hash',
|
||||||
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
|
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
|
||||||
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
|
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
|
||||||
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
|
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
|
||||||
@@ -164,8 +307,12 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`started_at` DATETIME NULL,
|
`started_at` DATETIME NULL,
|
||||||
`finished_at` DATETIME NULL,
|
`finished_at` DATETIME NULL,
|
||||||
|
`active_execute_slot` TINYINT GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END
|
||||||
|
) STORED,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
|
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
|
||||||
|
UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`),
|
||||||
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
|
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
);
|
);
|
||||||
@@ -177,6 +324,21 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
|
||||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
|
||||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
||||||
|
self::addColumnIfMissing(
|
||||||
|
$db,
|
||||||
|
'xlvask_autopilot_runs',
|
||||||
|
'active_execute_slot',
|
||||||
|
"TINYINT GENERATED ALWAYS AS (CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END) STORED AFTER finished_at"
|
||||||
|
);
|
||||||
|
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_active_execute_run')) {
|
||||||
|
if ($db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`)') === false) {
|
||||||
|
throw new \RuntimeException('The unique active XL Vask execute-run index could not be created.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_autopilot_run_idempotency')
|
||||||
|
&& $db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') === false) {
|
||||||
|
throw new \RuntimeException('The unique XL Vask run idempotency index could not be created.');
|
||||||
|
}
|
||||||
|
|
||||||
$db->query(
|
$db->query(
|
||||||
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
|
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
|
||||||
@@ -241,6 +403,7 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
`created_by` INT NULL,
|
`created_by` INT NULL,
|
||||||
`activated_by` INT NULL,
|
`activated_by` INT NULL,
|
||||||
`activated_at` DATETIME NULL,
|
`activated_at` DATETIME NULL,
|
||||||
|
`invalidated_at` DATETIME NULL,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
|
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
|
||||||
@@ -251,12 +414,15 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
|
||||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
|
||||||
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'automation_identity_hash', 'CHAR(64) NULL AFTER segment_key');
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'invalidated_at', 'DATETIME NULL AFTER activated_at');
|
||||||
|
|
||||||
$db->query(
|
$db->query(
|
||||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
|
||||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
`suggestion_id` INT NOT NULL,
|
`suggestion_id` INT NOT NULL,
|
||||||
`outcome` VARCHAR(16) NOT NULL,
|
`outcome` VARCHAR(16) NOT NULL,
|
||||||
|
`adjudication_outcome` VARCHAR(32) NULL,
|
||||||
`adjudicated_by` INT NOT NULL,
|
`adjudicated_by` INT NOT NULL,
|
||||||
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@@ -264,7 +430,6 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
|
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
|
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
|
||||||
// The nullable legacy id supports an idempotent, non-destructive backfill.
|
// The nullable legacy id supports an idempotent, non-destructive backfill.
|
||||||
$db->query(
|
$db->query(
|
||||||
@@ -281,6 +446,12 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
|
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
);
|
);
|
||||||
|
self::addColumnIfMissing(
|
||||||
|
$db,
|
||||||
|
'xlvask_automation_calibration_label_events',
|
||||||
|
'adjudication_outcome',
|
||||||
|
'VARCHAR(32) NULL AFTER outcome'
|
||||||
|
);
|
||||||
$db->query(
|
$db->query(
|
||||||
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
|
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
|
||||||
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
|
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
|
||||||
@@ -302,6 +473,91 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
|
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_state` (
|
||||||
|
`id` TINYINT NOT NULL,
|
||||||
|
`policy_version` VARCHAR(64) NOT NULL,
|
||||||
|
`planner_identity_hash` CHAR(64) NOT NULL,
|
||||||
|
`stage` VARCHAR(32) NOT NULL DEFAULT 'off',
|
||||||
|
`halted` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`attach_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`create_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`halt_reason` TEXT NULL,
|
||||||
|
`attach_halt_reason` TEXT NULL,
|
||||||
|
`create_halt_reason` TEXT NULL,
|
||||||
|
`halted_at` DATETIME NULL,
|
||||||
|
`halted_by` INT NULL,
|
||||||
|
`attach_activated_at` DATETIME NULL,
|
||||||
|
`attach_activated_by` INT NULL,
|
||||||
|
`create_activated_at` DATETIME NULL,
|
||||||
|
`create_activated_by` INT NULL,
|
||||||
|
`expected_version` INT NOT NULL DEFAULT 1,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'stage', "VARCHAR(32) NOT NULL DEFAULT 'off' AFTER planner_identity_hash");
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'attach_halt_reason', 'TEXT NULL AFTER halt_reason');
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'create_halt_reason', 'TEXT NULL AFTER attach_halt_reason');
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_previews` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`selection_hash` CHAR(64) NOT NULL,
|
||||||
|
`requested_transition` VARCHAR(32) NOT NULL,
|
||||||
|
`payload_json` LONGTEXT NOT NULL,
|
||||||
|
`created_by` INT NOT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`applied_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_xlvask_policy_preview_expiry` (`expires_at`, `applied_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_events` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`event_type` VARCHAR(48) NOT NULL,
|
||||||
|
`actor_id` INT NOT NULL,
|
||||||
|
`details_json` LONGTEXT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_xlvask_policy_event_time` (`created_at`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_action_events` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`suggestion_id` INT NOT NULL,
|
||||||
|
`run_id` BIGINT NULL,
|
||||||
|
`hall_id` VARCHAR(191) NOT NULL,
|
||||||
|
`action` VARCHAR(32) NOT NULL,
|
||||||
|
`source` VARCHAR(32) NOT NULL,
|
||||||
|
`policy_version` VARCHAR(64) NOT NULL,
|
||||||
|
`planner_identity_hash` CHAR(64) NOT NULL,
|
||||||
|
`review_outcome` VARCHAR(32) NULL,
|
||||||
|
`reviewed_by` INT NULL,
|
||||||
|
`reviewed_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`),
|
||||||
|
KEY `idx_xlvask_action_budget` (`action`, `created_at`),
|
||||||
|
KEY `idx_xlvask_action_hall_budget` (`hall_id`, `action`, `created_at`),
|
||||||
|
KEY `idx_xlvask_action_soak` (`source`, `action`, `created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'hall_id', "VARCHAR(191) NOT NULL DEFAULT '' AFTER run_id");
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'review_outcome', 'VARCHAR(32) NULL AFTER planner_identity_hash');
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_by', 'INT NULL AFTER review_outcome');
|
||||||
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_at', 'DATETIME NULL AFTER reviewed_by');
|
||||||
|
if (!self::indexExists($db, 'xlvask_automation_action_events', 'uniq_xlvask_action_event_suggestion')
|
||||||
|
&& $db->query('ALTER TABLE `xlvask_automation_action_events` ADD UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`)') === false) {
|
||||||
|
throw new \RuntimeException('The unique XL Vask action-event suggestion index could not be created.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function washIdUniquenessReady(): bool
|
public static function washIdUniquenessReady(): bool
|
||||||
@@ -353,10 +609,31 @@ class xlvask_usage_logs_schema_bootstrap
|
|||||||
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
||||||
{
|
{
|
||||||
if (!self::columnExists($db, $table, $column)) {
|
if (!self::columnExists($db, $table, $column)) {
|
||||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
if ($db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}") === false) {
|
||||||
|
throw new \RuntimeException("The required XL Vask column {$table}.{$column} could not be created.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function activeExecuteRunConflicts(object $db): array
|
||||||
|
{
|
||||||
|
if (!self::tableExists($db, 'xlvask_autopilot_runs')
|
||||||
|
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'mode')
|
||||||
|
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'status')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT COUNT(*) total FROM xlvask_autopilot_runs
|
||||||
|
WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')"
|
||||||
|
);
|
||||||
|
if ($result === false || !is_object($result)) {
|
||||||
|
return ['active_execute_preflight_unavailable'];
|
||||||
|
}
|
||||||
|
$row = $db->fetch_assoc($result);
|
||||||
|
$count = (int)($row['total'] ?? 0);
|
||||||
|
return $count > 1 ? ['multiple_active_execute_runs:' . $count] : [];
|
||||||
|
}
|
||||||
|
|
||||||
private static function tableExists(object $db, string $table): bool
|
private static function tableExists(object $db, string $table): bool
|
||||||
{
|
{
|
||||||
$table = self::escapeIdentifier($table);
|
$table = self::escapeIdentifier($table);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+24
-1
@@ -7,7 +7,11 @@ use traits\module_config_variable;
|
|||||||
|
|
||||||
class xlvask_automatic_order_attachment_enabled_c
|
class xlvask_automatic_order_attachment_enabled_c
|
||||||
{
|
{
|
||||||
use module_config_variable;
|
use module_config_variable {
|
||||||
|
setVariableValue as private setVariableValueInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool $policyWrite = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -26,4 +30,23 @@ class xlvask_automatic_order_attachment_enabled_c
|
|||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generic module config may kill automation but cannot activate it. */
|
||||||
|
public function setVariableValue(mixed $value): void
|
||||||
|
{
|
||||||
|
if (!self::$policyWrite && self::inputToBool($value)) {
|
||||||
|
throw new Exception('Automatic XL Vask attachment can only be enabled through the policy preview/apply flow.');
|
||||||
|
}
|
||||||
|
$this->setVariableValueInternal($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFromAutomationPolicy(bool $enabled): void
|
||||||
|
{
|
||||||
|
self::$policyWrite = true;
|
||||||
|
try {
|
||||||
|
$this->setVariableValueInternal($enabled);
|
||||||
|
} finally {
|
||||||
|
self::$policyWrite = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -7,7 +7,11 @@ use traits\module_config_variable;
|
|||||||
|
|
||||||
class xlvask_automatic_order_creation_enabled_c
|
class xlvask_automatic_order_creation_enabled_c
|
||||||
{
|
{
|
||||||
use module_config_variable;
|
use module_config_variable {
|
||||||
|
setVariableValue as private setVariableValueInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool $policyWrite = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -26,4 +30,23 @@ class xlvask_automatic_order_creation_enabled_c
|
|||||||
'false'
|
'false'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generic module config may kill automation but cannot activate it. */
|
||||||
|
public function setVariableValue(mixed $value): void
|
||||||
|
{
|
||||||
|
if (!self::$policyWrite && self::inputToBool($value)) {
|
||||||
|
throw new Exception('Automatic XL Vask creation can only be enabled through the policy preview/apply flow.');
|
||||||
|
}
|
||||||
|
$this->setVariableValueInternal($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFromAutomationPolicy(bool $enabled): void
|
||||||
|
{
|
||||||
|
self::$policyWrite = true;
|
||||||
|
try {
|
||||||
|
$this->setVariableValueInternal($enabled);
|
||||||
|
} finally {
|
||||||
|
self::$policyWrite = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class xlvask_tasks
|
|||||||
$this->runSyncUsage();
|
$this->runSyncUsage();
|
||||||
$this->runSyncVehicles();
|
$this->runSyncVehicles();
|
||||||
$this->runCleanupTasks();
|
$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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,15 +72,34 @@ class xlvask_tasks
|
|||||||
// - xlvask_usage_logs
|
// - xlvask_usage_logs
|
||||||
(new xlvask_customers_o())->importCustomers();
|
(new xlvask_customers_o())->importCustomers();
|
||||||
(new xlvask_vehicles_o())->importVehicles();
|
(new xlvask_vehicles_o())->importVehicles();
|
||||||
$hallIds = $this->configuredHallIds();
|
|
||||||
if ($hallIds !== []) {
|
|
||||||
$autopilot = new xlvask_autopilot_service();
|
|
||||||
$autopilot->createRun(['mode' => 'execute'], null, $hallIds);
|
|
||||||
$autopilot->processQueuedRuns(3);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Drain the durable autopilot queue without running the hourly upstream synchronization. */
|
||||||
public function processAutopilotQueue(int $limit = 3): array
|
public function processAutopilotQueue(int $limit = 3): array
|
||||||
{
|
{
|
||||||
@@ -86,6 +108,11 @@ class xlvask_tasks
|
|||||||
if (!$xlvask->config->synchronization_enabled->isTrue()) {
|
if (!$xlvask->config->synchronization_enabled->isTrue()) {
|
||||||
return [];
|
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);
|
return (new xlvask_autopilot_service())->processQueuedRuns($limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +385,9 @@ class xlvask_tasks
|
|||||||
$xlvask = new \classes\xlvask();
|
$xlvask = new \classes\xlvask();
|
||||||
// Require the module to be enabled
|
// Require the module to be enabled
|
||||||
$xlvask->requireModuleEnabled();
|
$xlvask->requireModuleEnabled();
|
||||||
|
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
(new xlvask_autopilot_service())->pruneExpiredData();
|
(new xlvask_autopilot_service())->pruneExpiredData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace xlvask\migrations;
|
||||||
|
|
||||||
|
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
||||||
|
|
||||||
|
use classes\xlvask_usage_logs_schema_bootstrap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioned, operator-invoked XL Vask AI auto-action migration.
|
||||||
|
*
|
||||||
|
* Preflight is read-only. apply() is intentionally not wired to HTTP routes, cron, constructors,
|
||||||
|
* readiness, or normal run processing. Operators must execute it through the controlled database
|
||||||
|
* migration procedure and retain the returned status artifact.
|
||||||
|
*/
|
||||||
|
final class migration_20260804_xlvask_ai_auto_policy_v2
|
||||||
|
{
|
||||||
|
public static function preflight(): array
|
||||||
|
{
|
||||||
|
return xlvask_usage_logs_schema_bootstrap::migrationStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function apply(): array
|
||||||
|
{
|
||||||
|
return xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -326,6 +326,7 @@ class xlvask_usage_logs_o extends db
|
|||||||
|
|
||||||
public static function sourceHashForAutomation(array $payload): string
|
public static function sourceHashForAutomation(array $payload): string
|
||||||
{
|
{
|
||||||
|
$payload = self::normalizeSourcePayload($payload);
|
||||||
$encoded = json_encode(
|
$encoded = json_encode(
|
||||||
self::sortSourceValue($payload),
|
self::sortSourceValue($payload),
|
||||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
||||||
@@ -352,6 +353,14 @@ class xlvask_usage_logs_o extends db
|
|||||||
$payload['WashItems'] = $decoded;
|
$payload['WashItems'] = $decoded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isset($payload['WashItems']) && is_array($payload['WashItems'])) {
|
||||||
|
$payload['WashItems'] = array_values($payload['WashItems']);
|
||||||
|
usort($payload['WashItems'], static function (mixed $left, mixed $right): int {
|
||||||
|
$leftJson = json_encode(self::sortSourceValue($left), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: '';
|
||||||
|
$rightJson = json_encode(self::sortSourceValue($right), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: '';
|
||||||
|
return strcmp($leftJson, $rightJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -10664,14 +10664,15 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Modules
|
- Modules
|
||||||
summary: Create an XLVask usage autopilot run
|
summary: Create an XLVask usage autopilot run
|
||||||
description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run mode evaluates without automatic execution.
|
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
|
operationId: createXlvaskUsageAutopilotRun
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
|
required: [mode]
|
||||||
properties:
|
properties:
|
||||||
dateFrom:
|
dateFrom:
|
||||||
type: string
|
type: string
|
||||||
@@ -10692,6 +10693,9 @@ paths:
|
|||||||
mode:
|
mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [execute, dry_run, replay]
|
enum: [execute, dry_run, replay]
|
||||||
|
idempotency_key:
|
||||||
|
type: string
|
||||||
|
maxLength: 191
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: XLVask usage autopilot run queued successfully
|
description: XLVask usage autopilot run queued successfully
|
||||||
@@ -10863,12 +10867,115 @@ paths:
|
|||||||
summary: Inspect XLVask automation activation readiness
|
summary: Inspect XLVask automation activation readiness
|
||||||
description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts.
|
description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts.
|
||||||
operationId: getXlvaskAutomationActivationReadiness
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Activation readiness returned successfully
|
description: Activation readiness returned successfully
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
'403': { $ref: '#/components/responses/Forbidden' }
|
'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:
|
/modules/xlvask/services/usage/automation/admin/calibrations/labels:
|
||||||
post:
|
post:
|
||||||
tags: [Modules]
|
tags: [Modules]
|
||||||
@@ -10884,7 +10991,7 @@ paths:
|
|||||||
required: [suggestion_id, outcome]
|
required: [suggestion_id, outcome]
|
||||||
properties:
|
properties:
|
||||||
suggestion_id: { type: integer }
|
suggestion_id: { type: integer }
|
||||||
outcome: { type: string, enum: [correct, incorrect] }
|
outcome: { type: string, enum: [correct, incorrect, duplicate, cross_hall, unaudited] }
|
||||||
responses:
|
responses:
|
||||||
'200': { description: Calibration label stored successfully }
|
'200': { description: Calibration label stored successfully }
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
|||||||
@@ -255,27 +255,7 @@ class moduleXLVaskRoute
|
|||||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission('modules_xlvask_import_usage');
|
self::requirePermission('modules_xlvask_import_usage');
|
||||||
$user = (new authentication())->get_user();
|
$response->error('Deprecated state-changing GET. Use POST /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||||
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;
|
|
||||||
$hallIds = array_values(array_filter(array_map(
|
|
||||||
static fn(mixed $id): string => trim((string)$id),
|
|
||||||
(array)$user->getGroup()->getDepartmentsScannersHallIds()
|
|
||||||
), static fn(string $id): bool => $id !== '' && strlen($id) <= 191));
|
|
||||||
$run = (new xlvask_autopilot_service())->createRun([
|
|
||||||
'dateFrom' => $dateFrom,
|
|
||||||
'dateTo' => $dateTo,
|
|
||||||
'mode' => 'execute',
|
|
||||||
'forceRefetch' => true,
|
|
||||||
], (int)$user->id, $hallIds);
|
|
||||||
$response->success([
|
|
||||||
'deprecated' => true,
|
|
||||||
'replacement' => '/modules/xlvask/services/usage/autopilot-runs',
|
|
||||||
'run' => $run,
|
|
||||||
], 202);
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
|
'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace routes;
|
|||||||
|
|
||||||
require_once WD . '/classes/xlvask_automation_service.php';
|
require_once WD . '/classes/xlvask_automation_service.php';
|
||||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||||
|
require_once WD . '/classes/xlvask_automation_policy_service.php';
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use classes\redis;
|
use classes\redis;
|
||||||
@@ -12,6 +13,7 @@ use classes\stripe;
|
|||||||
use classes\xlvask;
|
use classes\xlvask;
|
||||||
use classes\xlvask_autopilot_service;
|
use classes\xlvask_autopilot_service;
|
||||||
use classes\xlvask_automation_service;
|
use classes\xlvask_automation_service;
|
||||||
|
use classes\xlvask_automation_policy_service;
|
||||||
use objects\collected_order_invoices_o;
|
use objects\collected_order_invoices_o;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
use objects\economic_module_orders;
|
use objects\economic_module_orders;
|
||||||
@@ -34,11 +36,7 @@ class xlvaskUsageLogsRoute
|
|||||||
$response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly)
|
$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
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
self::requirePermission($permission_list_own);
|
if (!self::hasPermission($permission_list_all)) {
|
||||||
// Check if the user has permission to list all entries
|
|
||||||
if (self::hasPermission($permission_list_all)) {
|
|
||||||
$this->requirePermission($permission_list_all);
|
|
||||||
} else {
|
|
||||||
$this->requirePermission($permission_list_own);
|
$this->requirePermission($permission_list_own);
|
||||||
}
|
}
|
||||||
// Get the user object
|
// Get the user object
|
||||||
@@ -165,7 +163,9 @@ class xlvaskUsageLogsRoute
|
|||||||
|
|
||||||
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
|
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('list_xlvask_usage_orders_own');
|
if (!self::hasPermission('list_xlvask_usage_orders_all')) {
|
||||||
|
$this->requirePermission('list_xlvask_usage_orders_own');
|
||||||
|
}
|
||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$response->error('Invalid session', 400);
|
$response->error('Invalid session', 400);
|
||||||
@@ -183,6 +183,7 @@ class xlvaskUsageLogsRoute
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
|
'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',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,11 +219,103 @@ class xlvaskUsageLogsRoute
|
|||||||
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
|
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||||
$response->success((new xlvask_autopilot_service())->activationReadiness());
|
$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(
|
||||||
|
$dateFrom,
|
||||||
|
$dateTo,
|
||||||
|
self::allowedHallIdsForUser($user)
|
||||||
|
));
|
||||||
}, [
|
}, [
|
||||||
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
|
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->get('/modules/xlvask/services/usage/automation/capabilities', function () {
|
||||||
|
global $response;
|
||||||
|
if (!self::hasPermission('list_xlvask_usage_orders_all')
|
||||||
|
&& !self::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, self::allowedHallIdsForUser($user));
|
||||||
|
$canManage = self::hasPermission('manage_xlvask_usage_automation');
|
||||||
|
$canManagePolicy = self::hasPermission('superuser_xlvask_automation_activate');
|
||||||
|
$response->success([
|
||||||
|
'can_view' => true,
|
||||||
|
'can_review' => $canManage,
|
||||||
|
'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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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(
|
||||||
|
self::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 () {
|
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||||
@@ -249,11 +342,19 @@ class xlvaskUsageLogsRoute
|
|||||||
if (!$user) {
|
if (!$user) {
|
||||||
$response->error('Invalid session', 400);
|
$response->error('Invalid session', 400);
|
||||||
}
|
}
|
||||||
$response->success((new xlvask_autopilot_service())->adjudicateCalibrationLabel(
|
$allowedHallIds = self::allowedHallIdsForUser($user);
|
||||||
|
$result = (new xlvask_autopilot_service())->adjudicateCalibrationLabel(
|
||||||
(int)$this->getParameter('suggestion_id'),
|
(int)$this->getParameter('suggestion_id'),
|
||||||
trim((string)$this->getParameter('outcome')),
|
trim((string)$this->getParameter('outcome')),
|
||||||
(int)$user->id
|
(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),
|
||||||
|
]);
|
||||||
}, [
|
}, [
|
||||||
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
|
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
|
||||||
]);
|
]);
|
||||||
@@ -355,40 +456,9 @@ class xlvaskUsageLogsRoute
|
|||||||
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
|
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
|
||||||
|
|
||||||
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
|
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
|
||||||
global $db, $response;
|
global $response;
|
||||||
$this->requirePermission('ignore_xlvask_usage_order');
|
$this->requirePermission('ignore_xlvask_usage_order');
|
||||||
|
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||||
$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, self::allowedHallIdsForUser($user));
|
|
||||||
|
|
||||||
$reason = $this->isParametersSet(['reason']) ? mb_substr(trim((string)$this->getParameter('reason')), 0, 1000) : null;
|
|
||||||
$reasonSql = $reason === null || $reason === ''
|
|
||||||
? 'NULL'
|
|
||||||
: "'" . $db->escape_string($reason) . "'";
|
|
||||||
|
|
||||||
(new xlvask_usage_logs_o())->structure();
|
|
||||||
$db->query(
|
|
||||||
"UPDATE xlvask_usage_logs
|
|
||||||
SET ignored_at = NOW(),
|
|
||||||
ignored_by = " . (int)$user->id . ",
|
|
||||||
ignored_reason = {$reasonSql},
|
|
||||||
resolution_state = 'ignored', certainty = 'none', planned_action = 'none',
|
|
||||||
expected_version = expected_version + 1
|
|
||||||
WHERE id = {$id}"
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->success([
|
|
||||||
'id' => $id,
|
|
||||||
'ignored' => true,
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
||||||
@@ -398,34 +468,7 @@ class xlvaskUsageLogsRoute
|
|||||||
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
|
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('manage_xlvask_usage_automation');
|
$this->requirePermission('manage_xlvask_usage_automation');
|
||||||
|
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : [];
|
|
||||||
if (!is_array($ids)) {
|
|
||||||
$ids = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
|
|
||||||
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
|
|
||||||
$limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100;
|
|
||||||
$allowedHallIds = self::allowedHallIdsForUser($user);
|
|
||||||
if ($allowedHallIds === []) {
|
|
||||||
$response->error('No XL Vask hall scope is available', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->success([
|
|
||||||
'run' => (new xlvask_autopilot_service())->createRun([
|
|
||||||
'mode' => 'execute',
|
|
||||||
'dateFrom' => $dateFrom,
|
|
||||||
'dateTo' => $dateTo,
|
|
||||||
'ids' => $ids,
|
|
||||||
'limit' => $limit,
|
|
||||||
], (int)$user->id, $allowedHallIds),
|
|
||||||
], 202);
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
|
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
|
||||||
@@ -435,25 +478,7 @@ class xlvaskUsageLogsRoute
|
|||||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
|
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('manage_xlvask_usage_automation');
|
$this->requirePermission('manage_xlvask_usage_automation');
|
||||||
|
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||||
$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, self::allowedHallIdsForUser($user));
|
|
||||||
|
|
||||||
$response->success([
|
|
||||||
'run' => (new xlvask_autopilot_service())->createRun([
|
|
||||||
'mode' => 'dry_run',
|
|
||||||
'ids' => [$id],
|
|
||||||
'limit' => 1,
|
|
||||||
], (int)$user->id, self::allowedHallIdsForUser($user)),
|
|
||||||
], 202);
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
|
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
|
||||||
@@ -600,8 +625,21 @@ class xlvaskUsageLogsRoute
|
|||||||
|
|
||||||
private static function allowedHallIdsForUser(object $user): array
|
private static function allowedHallIdsForUser(object $user): array
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
|
if (self::hasPermission('list_xlvask_usage_orders_all')) {
|
||||||
|
$result = $db->query(
|
||||||
|
"SELECT DISTINCT HallId FROM plate_scanners
|
||||||
|
WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL"
|
||||||
|
);
|
||||||
|
$hallIds = $result === false ? [] : array_map(
|
||||||
|
static fn(array $row): string => trim((string)($row['HallId'] ?? '')),
|
||||||
|
$db->fetch_all($result)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$hallIds = (array)$user->getGroup()->getDepartmentsScannersHallIds();
|
||||||
|
}
|
||||||
return array_values(array_unique(array_filter(
|
return array_values(array_unique(array_filter(
|
||||||
array_map(static fn(mixed $id): string => trim((string)$id), (array)$user->getGroup()->getDepartmentsScannersHallIds()),
|
array_map(static fn(mixed $id): string => trim((string)$id), $hallIds),
|
||||||
static fn(string $id): bool => $id !== '' && strlen($id) <= 191
|
static fn(string $id): bool => $id !== '' && strlen($id) <= 191
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use classes\xlvask_automation_service;
|
use classes\xlvask_automation_service;
|
||||||
use classes\xlvask_autopilot_service;
|
use classes\xlvask_autopilot_service;
|
||||||
|
use classes\openai;
|
||||||
use objects\xlvask_usage_logs_o;
|
use objects\xlvask_usage_logs_o;
|
||||||
|
|
||||||
require_once WD . '/classes/xlvask_automation_service.php';
|
require_once WD . '/classes/xlvask_automation_service.php';
|
||||||
@@ -158,7 +159,8 @@ it('keeps automatic XL Vask execution scoped to exact attachments', function ():
|
|||||||
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||||
|
|
||||||
expect($serviceContent)
|
expect($serviceContent)
|
||||||
->toContain('&& $this->isExactAttachSuggestionForContext($suggestion, $context)')
|
->toContain('openAiAttachHardGuardsPass($suggestion, $context)')
|
||||||
|
->toContain('self::isExactItemMatchForAutomation((array)($context[\'items\'] ?? []), (array)($candidate[\'order_items\'] ?? []))')
|
||||||
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
|
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
|
||||||
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
|
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
|
||||||
});
|
});
|
||||||
@@ -225,14 +227,32 @@ it('builds order-independent revision hashes for XL Vask source payloads', funct
|
|||||||
->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second));
|
->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 {
|
it('uses the existing OpenAI module with strict no-retention planner settings', function (): void {
|
||||||
$openAi = file_get_contents(WD . '/classes/openai.php');
|
$openAi = file_get_contents(WD . '/classes/openai.php');
|
||||||
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||||
|
|
||||||
expect($openAi)->toContain("'store' => false")
|
expect($openAi)->toContain("'store' => false")
|
||||||
|
->toContain("'role' => 'developer'")
|
||||||
|
->toContain("'role' => 'user'")
|
||||||
|
->toContain("if (\$status !== 'completed')")
|
||||||
|
->toContain("=== 'refusal'")
|
||||||
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
|
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
|
||||||
->toContain('candidate_order_id')
|
->toContain('candidate_order_id')
|
||||||
->toContain('opaque_context_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 {
|
it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void {
|
||||||
@@ -260,6 +280,14 @@ it('uses a dedicated queued XL Vask autopilot service with scoped durable runs',
|
|||||||
->toContain("phase = 'retry_wait'");
|
->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 {
|
it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void {
|
||||||
expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([
|
expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([
|
||||||
'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true,
|
'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true,
|
||||||
@@ -293,6 +321,17 @@ it('fails preview snapshots closed when source hash or optimistic version change
|
|||||||
->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 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 {
|
it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void {
|
||||||
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
|
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
|
||||||
expect($serviceContent)
|
expect($serviceContent)
|
||||||
@@ -311,19 +350,324 @@ it('keeps XL Vask automation execution inside a transactional revalidation bound
|
|||||||
->toContain('$connection->commit()')
|
->toContain('$connection->commit()')
|
||||||
->toContain('$connection->rollback()')
|
->toContain('$connection->rollback()')
|
||||||
->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE')
|
->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE')
|
||||||
->toContain("SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{\$washId}')) 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.');
|
->toContain('XL Vask-kildedata blev ændret efter evalueringen.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps OpenAI advisory-only and derives automatic certainty from hard guards', function (): void {
|
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');
|
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||||
expect($serviceContent)
|
expect($serviceContent)
|
||||||
->toContain("=== self::SOURCE_OPENAI")
|
->toContain("!== self::SOURCE_OPENAI")
|
||||||
->toContain('hardGuardsPassForCertainty($suggestion, $context)')
|
->toContain('hardGuardsPassForCertainty($suggestion, $context)')
|
||||||
|
->toContain('openAiAttachHardGuardsPass($suggestion, $context)')
|
||||||
|
->toContain('policyAllowsActionReadOnly($action)')
|
||||||
->toContain('sourceIsStableForAutomatic($context)')
|
->toContain('sourceIsStableForAutomatic($context)')
|
||||||
->toContain('washIdUniquenessReady()');
|
->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' => 'gpt-5.6-sol',
|
||||||
|
'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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void {
|
||||||
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||||
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
|
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ it('keeps automation metadata out of the strict legacy XL Vask helper payload',
|
|||||||
->not->toContain("'last_run_id'");
|
->not->toContain("'last_run_id'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adapts the legacy XL Vask usage import route to a scoped queued autopilot run', function (): void {
|
it('keeps the legacy state-changing XL Vask usage import GET non-mutating', function (): void {
|
||||||
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
|
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
|
||||||
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||||
|
|
||||||
@@ -84,10 +84,9 @@ it('adapts the legacy XL Vask usage import route to a scoped queued autopilot ru
|
|||||||
$automation = (string)$automation;
|
$automation = (string)$automation;
|
||||||
|
|
||||||
expect($route)
|
expect($route)
|
||||||
->toContain("getParameter('dateFrom')")
|
->toContain('Deprecated state-changing GET.')
|
||||||
->toContain("getParameter('dateTo')")
|
->toContain('Use POST /modules/xlvask/services/usage/autopilot-runs.')
|
||||||
->toContain("'replacement' => '/modules/xlvask/services/usage/autopilot-runs'")
|
->not->toContain("'forceRefetch' => true")
|
||||||
->toContain("'forceRefetch' => true")
|
|
||||||
->not->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
|
->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')");
|
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
|
||||||
});
|
});
|
||||||
@@ -113,13 +112,48 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi
|
|||||||
it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void {
|
it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void {
|
||||||
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
|
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
|
||||||
expect($route)
|
expect($route)
|
||||||
->toContain("'mode' => 'execute'")
|
->toContain('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.')
|
||||||
->toContain("'mode' => 'dry_run'")
|
|
||||||
->toContain('Use the server-generated automation decision preview and apply endpoints.')
|
->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())->runPending(')
|
||||||
->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById(');
|
->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 (!self::hasPermission(\$permission_list_all))")
|
||||||
|
->toContain("if (self::hasPermission('list_xlvask_usage_orders_all'))")
|
||||||
|
->toContain("if (!self::hasPermission('list_xlvask_usage_orders_all'))")
|
||||||
|
->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('returns revision and resolution state on XL Vask usage order rows', function (): void {
|
||||||
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
|
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
|
||||||
|
|
||||||
@@ -161,6 +195,15 @@ it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): v
|
|||||||
->toContain('operationId: generateXlvaskCalibrationArtifact')
|
->toContain('operationId: generateXlvaskCalibrationArtifact')
|
||||||
->toContain('operationId: activateXlvaskCalibrationArtifact')
|
->toContain('operationId: activateXlvaskCalibrationArtifact')
|
||||||
->toContain('operationId: activateXlvaskWashIdUniqueness');
|
->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('wires preview-bound bulk decisions through the transactional autopilot service', function (): void {
|
||||||
@@ -185,3 +228,11 @@ it('wires preview-bound bulk decisions through the transactional autopilot servi
|
|||||||
->toContain('expected_version')
|
->toContain('expected_version')
|
||||||
->toContain('source_hash');
|
->toContain('source_hash');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let pending automation schema block ordinary invoice period operations', function (): void {
|
||||||
|
$bootstrap = (string)file_get_contents(WD . '/classes/invoice_period_flag_schema_bootstrap.php');
|
||||||
|
|
||||||
|
expect($bootstrap)
|
||||||
|
->not->toContain('xlvask_usage_logs_schema_bootstrap::ensureTables()')
|
||||||
|
->toContain('XL Vask automation migration is pending');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user