xlvask-autopilot (#347)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
co-authored by
Jeppe Bundgaard
parent
db9f589bf7
commit
23fc410d25
@@ -133,7 +133,20 @@ class openai implements openai_i
|
||||
if ($resolvedModel === '') {
|
||||
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
|
||||
}
|
||||
return [...$decoded, '_openai_response_model' => $resolvedModel];
|
||||
$usage = (array)($response['usage'] ?? []);
|
||||
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
||||
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
||||
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
|
||||
return [
|
||||
...$decoded,
|
||||
'_openai_response_model' => $resolvedModel,
|
||||
'_openai_usage' => [
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $totalTokens,
|
||||
'service_tier' => (string)($response['service_tier'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getLPRSchema(): array
|
||||
|
||||
@@ -40,8 +40,36 @@ class xlvask_automation_service
|
||||
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 const AI_TIMELINES = ['priority', 'standard', 'economy'];
|
||||
private const DEFAULT_AI_TIMELINE = 'standard';
|
||||
private const DEFAULT_AI_BATCH_SIZE_BY_TIMELINE = [
|
||||
'priority' => 500,
|
||||
'standard' => 150,
|
||||
'economy' => 50,
|
||||
];
|
||||
private const DEFAULT_AI_INPUT_USD_PER_1M = 0.50;
|
||||
private const DEFAULT_AI_OUTPUT_USD_PER_1M = 2.00;
|
||||
private ?int $runId = null;
|
||||
private bool $readOnlyEvaluation = false;
|
||||
/** @var array{timeline:string,batch_size:int,max_cost_usd:?float,input_usd_per_1m:float,output_usd_per_1m:float} */
|
||||
private array $aiPolicy = [
|
||||
'timeline' => self::DEFAULT_AI_TIMELINE,
|
||||
'batch_size' => self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[self::DEFAULT_AI_TIMELINE],
|
||||
'max_cost_usd' => null,
|
||||
'input_usd_per_1m' => self::DEFAULT_AI_INPUT_USD_PER_1M,
|
||||
'output_usd_per_1m' => self::DEFAULT_AI_OUTPUT_USD_PER_1M,
|
||||
];
|
||||
/** @var array{requests:int,cache_hits:int,input_tokens:int,output_tokens:int,total_tokens:int,estimated_cost_usd:float,budget_exhausted:bool,stop_reason:?string} */
|
||||
private array $aiUsage = [
|
||||
'requests' => 0,
|
||||
'cache_hits' => 0,
|
||||
'input_tokens' => 0,
|
||||
'output_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'estimated_cost_usd' => 0.0,
|
||||
'budget_exhausted' => false,
|
||||
'stop_reason' => null,
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -60,6 +88,31 @@ class xlvask_automation_service
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAiExecutionPolicy(array $policy): self
|
||||
{
|
||||
$timeline = strtolower(trim((string)($policy['timeline'] ?? self::DEFAULT_AI_TIMELINE)));
|
||||
if (!in_array($timeline, self::AI_TIMELINES, true)) {
|
||||
$timeline = self::DEFAULT_AI_TIMELINE;
|
||||
}
|
||||
$defaultBatch = self::DEFAULT_AI_BATCH_SIZE_BY_TIMELINE[$timeline];
|
||||
$batchSize = isset($policy['batch_size']) ? (int)$policy['batch_size'] : $defaultBatch;
|
||||
$batchSize = max(1, min(500, $batchSize));
|
||||
$maxCost = isset($policy['max_cost_usd']) && $policy['max_cost_usd'] !== null
|
||||
? max(0.0, (float)$policy['max_cost_usd'])
|
||||
: null;
|
||||
$inputRate = isset($policy['input_usd_per_1m']) ? max(0.0, (float)$policy['input_usd_per_1m']) : self::DEFAULT_AI_INPUT_USD_PER_1M;
|
||||
$outputRate = isset($policy['output_usd_per_1m']) ? max(0.0, (float)$policy['output_usd_per_1m']) : self::DEFAULT_AI_OUTPUT_USD_PER_1M;
|
||||
$this->aiPolicy = [
|
||||
'timeline' => $timeline,
|
||||
'batch_size' => $batchSize,
|
||||
'max_cost_usd' => $maxCost,
|
||||
'input_usd_per_1m' => $inputRate,
|
||||
'output_usd_per_1m' => $outputRate,
|
||||
];
|
||||
$this->resetAiUsage();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public static function openAiFailureRequiresDurableRetry(openai_request_exception $exception, ?int $runId): bool
|
||||
{
|
||||
return $exception->retryable && $runId !== null && $runId > 0;
|
||||
@@ -416,6 +469,7 @@ class xlvask_automation_service
|
||||
$consecutiveFailures = 0;
|
||||
$circuitBreaker = null;
|
||||
$this->setReadOnlyEvaluation($readOnlyEvaluation);
|
||||
$this->resetAiUsage();
|
||||
foreach ($rows as $row) {
|
||||
if ($heartbeat !== null) {
|
||||
$heartbeat();
|
||||
@@ -475,6 +529,7 @@ class xlvask_automation_service
|
||||
'automatic_links' => $autoLinks,
|
||||
'automatic_creations' => $autoCreates,
|
||||
'circuit_breaker' => $circuitBreaker,
|
||||
'ai_usage' => $this->aiUsage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -983,13 +1038,21 @@ class xlvask_automation_service
|
||||
|
||||
$cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature);
|
||||
$result = $this->loadOpenAiCacheResult($cacheKey);
|
||||
$fromCache = $result !== null;
|
||||
if ($fromCache) {
|
||||
$this->aiUsage['cache_hits']++;
|
||||
}
|
||||
if ($result === null) {
|
||||
if ($this->readOnlyEvaluation) {
|
||||
return null;
|
||||
}
|
||||
if ($this->openAiBudgetReached()) {
|
||||
return null;
|
||||
}
|
||||
$openai = new openai();
|
||||
$result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL);
|
||||
$result = $this->sanitizeOpenAiResult($result, $context);
|
||||
$this->recordOpenAiUsage((array)($result['usage'] ?? []));
|
||||
if (!$this->readOnlyEvaluation) {
|
||||
$this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result);
|
||||
}
|
||||
@@ -1037,6 +1100,7 @@ class xlvask_automation_service
|
||||
'risk_flags' => array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))),
|
||||
'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))),
|
||||
'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')),
|
||||
'usage' => (array)($result['usage'] ?? []),
|
||||
];
|
||||
} catch (openai_request_exception $exception) {
|
||||
// Retryable provider/transport failures must reach the durable run
|
||||
@@ -1084,6 +1148,12 @@ class xlvask_automation_service
|
||||
'evidence' => $stringList($result['evidence'] ?? []),
|
||||
'contradictions' => $stringList($result['contradictions'] ?? []),
|
||||
'plan_steps' => $planSteps,
|
||||
'usage' => [
|
||||
'input_tokens' => max(0, (int)($result['_openai_usage']['input_tokens'] ?? 0)),
|
||||
'output_tokens' => max(0, (int)($result['_openai_usage']['output_tokens'] ?? 0)),
|
||||
'total_tokens' => max(0, (int)($result['_openai_usage']['total_tokens'] ?? 0)),
|
||||
'service_tier' => (string)($result['_openai_usage']['service_tier'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2660,6 +2730,76 @@ class xlvask_automation_service
|
||||
return self::normalizeRegistrationForAutomation($registration);
|
||||
}
|
||||
|
||||
private function resetAiUsage(): void
|
||||
{
|
||||
$this->aiUsage = [
|
||||
'requests' => 0,
|
||||
'cache_hits' => 0,
|
||||
'input_tokens' => 0,
|
||||
'output_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'estimated_cost_usd' => 0.0,
|
||||
'budget_exhausted' => false,
|
||||
'stop_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function openAiBudgetReached(): bool
|
||||
{
|
||||
if ($this->aiUsage['budget_exhausted']) {
|
||||
return true;
|
||||
}
|
||||
if ($this->aiUsage['requests'] >= (int)$this->aiPolicy['batch_size']) {
|
||||
$this->aiUsage['budget_exhausted'] = true;
|
||||
$this->aiUsage['stop_reason'] = 'ai_batch_size_reached';
|
||||
return true;
|
||||
}
|
||||
$maxCost = $this->aiPolicy['max_cost_usd'];
|
||||
if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) {
|
||||
$this->aiUsage['budget_exhausted'] = true;
|
||||
$this->aiUsage['stop_reason'] = 'ai_cost_limit_reached';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function recordOpenAiUsage(array $usage): void
|
||||
{
|
||||
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
||||
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
||||
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
|
||||
$estimatedCostUsd = (($inputTokens * (float)$this->aiPolicy['input_usd_per_1m'])
|
||||
+ ($outputTokens * (float)$this->aiPolicy['output_usd_per_1m'])) / 1000000;
|
||||
|
||||
$this->aiUsage['requests']++;
|
||||
$this->aiUsage['input_tokens'] += $inputTokens;
|
||||
$this->aiUsage['output_tokens'] += $outputTokens;
|
||||
$this->aiUsage['total_tokens'] += $totalTokens;
|
||||
$this->aiUsage['estimated_cost_usd'] = round($this->aiUsage['estimated_cost_usd'] + $estimatedCostUsd, 6);
|
||||
|
||||
try {
|
||||
$usageService = new module_usage_service();
|
||||
$usageService->recordUsage('openai', 'api_calls', 1, [
|
||||
'run_id' => $this->runId,
|
||||
'timeline' => $this->aiPolicy['timeline'],
|
||||
]);
|
||||
if ($totalTokens > 0) {
|
||||
$usageService->recordUsage('openai', 'total_tokens', $totalTokens, [
|
||||
'run_id' => $this->runId,
|
||||
'timeline' => $this->aiPolicy['timeline'],
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Usage metrics are observability controls and must not break automation flow.
|
||||
}
|
||||
|
||||
$maxCost = $this->aiPolicy['max_cost_usd'];
|
||||
if ($maxCost !== null && $this->aiUsage['estimated_cost_usd'] >= $maxCost) {
|
||||
$this->aiUsage['budget_exhausted'] = true;
|
||||
$this->aiUsage['stop_reason'] = 'ai_cost_limit_reached';
|
||||
}
|
||||
}
|
||||
|
||||
private function isOpenAiEnabled(): bool
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ class xlvask_autopilot_service
|
||||
{
|
||||
public const POLICY_VERSION = xlvask_automation_service::POLICY_VERSION;
|
||||
private const PREVIEW_TTL_SECONDS = 900;
|
||||
private const AI_TIMELINES = ['priority', 'standard', 'economy'];
|
||||
|
||||
public static function modeCapabilities(string $mode): array
|
||||
{
|
||||
@@ -106,6 +107,11 @@ class xlvask_autopilot_service
|
||||
}
|
||||
}
|
||||
$forceRefetch = filter_var($input['forceRefetch'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
$aiTimeline = $this->normalizeAiTimeline($input['aiTimeline'] ?? null);
|
||||
$aiBatchSize = $this->normalizeAiBatchSize($input['aiBatchSize'] ?? null, $aiTimeline);
|
||||
$aiMaxCostUsd = $this->normalizeAiMaxCostUsd($input['aiMaxCostUsd'] ?? null);
|
||||
$aiInputUsdPer1m = $this->normalizeAiRate($input['aiInputUsdPer1mUsd'] ?? null, 0.5);
|
||||
$aiOutputUsdPer1m = $this->normalizeAiRate($input['aiOutputUsdPer1mUsd'] ?? null, 2.0);
|
||||
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
|
||||
if ($allowedHallIds === []) {
|
||||
throw new Exception('No XL Vask hall scope is available for this user.');
|
||||
@@ -134,6 +140,11 @@ class xlvask_autopilot_service
|
||||
'limit' => $requestedLimit,
|
||||
'force_refetch' => $forceRefetch,
|
||||
'scope_hall_ids' => $allowedHallIds,
|
||||
'ai_timeline' => $aiTimeline,
|
||||
'ai_batch_size' => $aiBatchSize,
|
||||
'ai_max_cost_usd' => $aiMaxCostUsd,
|
||||
'ai_input_usd_per_1m' => $aiInputUsdPer1m,
|
||||
'ai_output_usd_per_1m' => $aiOutputUsdPer1m,
|
||||
]);
|
||||
$idsJson = $db->escape_string(json_encode($ids, JSON_UNESCAPED_SLASHES) ?: '[]');
|
||||
$scopeJson = $db->escape_string(json_encode($allowedHallIds, JSON_UNESCAPED_SLASHES) ?: '[]');
|
||||
@@ -142,13 +153,19 @@ class xlvask_autopilot_service
|
||||
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
|
||||
$keySql = $db->escape_string($key);
|
||||
$modeSql = $db->escape_string($mode);
|
||||
$aiTimelineSql = $db->escape_string($aiTimeline);
|
||||
$aiMaxCostSql = $aiMaxCostUsd === null ? 'NULL' : (string)round($aiMaxCostUsd, 4);
|
||||
$aiInputRateSql = (string)round($aiInputUsdPer1m, 4);
|
||||
$aiOutputRateSql = (string)round($aiOutputUsdPer1m, 4);
|
||||
|
||||
if ($db->query(
|
||||
"INSERT INTO xlvask_autopilot_runs
|
||||
(idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json,
|
||||
requested_limit, scope_hall_ids_json, created_by)
|
||||
requested_limit, scope_hall_ids_json, ai_timeline, ai_batch_size, ai_max_cost_usd,
|
||||
ai_input_usd_per_1m_usd, ai_output_usd_per_1m_usd, created_by)
|
||||
VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ",
|
||||
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql})
|
||||
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', '{$aiTimelineSql}', {$aiBatchSize}, {$aiMaxCostSql},
|
||||
{$aiInputRateSql}, {$aiOutputRateSql}, {$actorSql})
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
|
||||
) === false) {
|
||||
throw new Exception('The XL Vask autopilot run could not be queued atomically.');
|
||||
@@ -258,6 +275,14 @@ class xlvask_autopilot_service
|
||||
$renewLease();
|
||||
$automation = new xlvask_automation_service();
|
||||
$automation->setRunContext($runId);
|
||||
$automation->setAiExecutionPolicy([
|
||||
'timeline' => (string)($run['ai_timeline'] ?? 'standard'),
|
||||
'batch_size' => (int)($run['ai_batch_size'] ?? 150),
|
||||
'max_cost_usd' => isset($run['ai_max_cost_usd']) && $run['ai_max_cost_usd'] !== null
|
||||
? (float)$run['ai_max_cost_usd'] : null,
|
||||
'input_usd_per_1m' => (float)($run['ai_input_usd_per_1m_usd'] ?? 0.5),
|
||||
'output_usd_per_1m' => (float)($run['ai_output_usd_per_1m_usd'] ?? 2.0),
|
||||
]);
|
||||
$allowExecute = $capabilities['execute_actions'];
|
||||
$results = $automation->runPending(
|
||||
$dateFrom,
|
||||
@@ -274,6 +299,8 @@ class xlvask_autopilot_service
|
||||
$this->persistRunItems($runId, $results['results'] ?? []);
|
||||
$summary = $this->getSummary($dateFrom, $dateTo, $scopeHallIds);
|
||||
$summary['import'] = $importSummary;
|
||||
$summary['ai_usage'] = (array)($results['ai_usage'] ?? []);
|
||||
$summary['ai_timeline'] = (string)($run['ai_timeline'] ?? 'standard');
|
||||
$circuitBreaker = $results['circuit_breaker'] ?? null;
|
||||
$warning = $circuitBreaker === null ? null : 'Autopilot stopped early: ' . (string)$circuitBreaker;
|
||||
$summary['circuit_breaker'] = $circuitBreaker;
|
||||
@@ -289,9 +316,21 @@ class xlvask_autopilot_service
|
||||
$status = $circuitBreaker === null ? 'completed' : 'completed_with_warnings';
|
||||
$phase = $circuitBreaker === null ? 'completed' : 'circuit_breaker';
|
||||
$warningSql = $warning === null ? 'NULL' : "'" . $db->escape_string($warning) . "'";
|
||||
$aiUsage = (array)($results['ai_usage'] ?? []);
|
||||
$aiRequests = (int)($aiUsage['requests'] ?? 0);
|
||||
$aiCacheHits = (int)($aiUsage['cache_hits'] ?? 0);
|
||||
$aiInputTokens = (int)($aiUsage['input_tokens'] ?? 0);
|
||||
$aiOutputTokens = (int)($aiUsage['output_tokens'] ?? 0);
|
||||
$aiTotalTokens = (int)($aiUsage['total_tokens'] ?? 0);
|
||||
$aiEstimatedCost = round((float)($aiUsage['estimated_cost_usd'] ?? 0), 6);
|
||||
$aiBudgetExhausted = !empty($aiUsage['budget_exhausted']) ? 1 : 0;
|
||||
$db->query(
|
||||
"UPDATE xlvask_autopilot_runs SET status = '{$status}', phase = '{$phase}', processed = {$processed},
|
||||
total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql}, finished_at = NOW()
|
||||
total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql},
|
||||
ai_requests = {$aiRequests}, ai_cache_hits = {$aiCacheHits},
|
||||
ai_input_tokens = {$aiInputTokens}, ai_output_tokens = {$aiOutputTokens},
|
||||
ai_total_tokens = {$aiTotalTokens}, ai_estimated_cost_usd = {$aiEstimatedCost},
|
||||
ai_budget_exhausted = {$aiBudgetExhausted}, finished_at = NOW()
|
||||
WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'"
|
||||
);
|
||||
if ($db->conn()->affected_rows !== 1) {
|
||||
@@ -1082,6 +1121,21 @@ class xlvask_autopilot_service
|
||||
'attempt_count' => (int)($row['attempt_count'] ?? 0),
|
||||
'max_attempts' => (int)($row['max_attempts'] ?? 3),
|
||||
'next_attempt_at' => $row['next_attempt_at'] ?: null,
|
||||
'ai' => [
|
||||
'timeline' => (string)($row['ai_timeline'] ?? 'standard'),
|
||||
'batch_size' => (int)($row['ai_batch_size'] ?? 150),
|
||||
'max_cost_usd' => isset($row['ai_max_cost_usd']) && $row['ai_max_cost_usd'] !== null
|
||||
? (float)$row['ai_max_cost_usd'] : null,
|
||||
'input_usd_per_1m_usd' => (float)($row['ai_input_usd_per_1m_usd'] ?? 0.5),
|
||||
'output_usd_per_1m_usd' => (float)($row['ai_output_usd_per_1m_usd'] ?? 2.0),
|
||||
'requests' => (int)($row['ai_requests'] ?? 0),
|
||||
'cache_hits' => (int)($row['ai_cache_hits'] ?? 0),
|
||||
'input_tokens' => (int)($row['ai_input_tokens'] ?? 0),
|
||||
'output_tokens' => (int)($row['ai_output_tokens'] ?? 0),
|
||||
'total_tokens' => (int)($row['ai_total_tokens'] ?? 0),
|
||||
'estimated_cost_usd' => (float)($row['ai_estimated_cost_usd'] ?? 0.0),
|
||||
'budget_exhausted' => (bool)($row['ai_budget_exhausted'] ?? false),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1119,6 +1173,52 @@ class xlvask_autopilot_service
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function normalizeAiTimeline(mixed $value): string
|
||||
{
|
||||
$timeline = strtolower(trim((string)($value ?? '')));
|
||||
if ($timeline === '') {
|
||||
return 'standard';
|
||||
}
|
||||
if (!in_array($timeline, self::AI_TIMELINES, true)) {
|
||||
throw new Exception('Invalid aiTimeline.');
|
||||
}
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function normalizeAiBatchSize(mixed $value, string $timeline): int
|
||||
{
|
||||
$defaults = ['priority' => 500, 'standard' => 150, 'economy' => 50];
|
||||
if ($value === null || trim((string)$value) === '') {
|
||||
return $defaults[$timeline] ?? 150;
|
||||
}
|
||||
if (!is_numeric($value)) {
|
||||
throw new Exception('Invalid aiBatchSize.');
|
||||
}
|
||||
return max(1, min(500, (int)$value));
|
||||
}
|
||||
|
||||
private function normalizeAiMaxCostUsd(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || trim((string)$value) === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_numeric($value)) {
|
||||
throw new Exception('Invalid aiMaxCostUsd.');
|
||||
}
|
||||
return max(0.0, round((float)$value, 4));
|
||||
}
|
||||
|
||||
private function normalizeAiRate(mixed $value, float $default): float
|
||||
{
|
||||
if ($value === null || trim((string)$value) === '') {
|
||||
return $default;
|
||||
}
|
||||
if (!is_numeric($value)) {
|
||||
throw new Exception('Invalid AI pricing input.');
|
||||
}
|
||||
return max(0.0, round((float)$value, 4));
|
||||
}
|
||||
|
||||
private function wilsonLowerBound(int $successes, int $total): float
|
||||
{
|
||||
if ($total < 1) {
|
||||
|
||||
@@ -130,6 +130,10 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
'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',
|
||||
'ai_timeline', 'ai_batch_size', 'ai_max_cost_usd',
|
||||
'ai_input_usd_per_1m_usd', 'ai_output_usd_per_1m_usd',
|
||||
'ai_requests', 'ai_cache_hits', 'ai_input_tokens', 'ai_output_tokens',
|
||||
'ai_total_tokens', 'ai_estimated_cost_usd', 'ai_budget_exhausted',
|
||||
],
|
||||
'xlvask_autopilot_run_items' => [
|
||||
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
|
||||
@@ -321,6 +325,18 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_timeline', "VARCHAR(16) NOT NULL DEFAULT 'standard' AFTER scope_hall_ids_json");
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_batch_size', 'INT NOT NULL DEFAULT 150 AFTER ai_timeline');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_max_cost_usd', 'DECIMAL(12,4) NULL AFTER ai_batch_size');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 0.5000 AFTER ai_max_cost_usd');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_usd_per_1m_usd', 'DECIMAL(12,4) NOT NULL DEFAULT 2.0000 AFTER ai_input_usd_per_1m_usd');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_requests', 'INT NOT NULL DEFAULT 0 AFTER total');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_cache_hits', 'INT NOT NULL DEFAULT 0 AFTER ai_requests');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_input_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_cache_hits');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_output_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_input_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_total_tokens', 'BIGINT NOT NULL DEFAULT 0 AFTER ai_output_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_estimated_cost_usd', 'DECIMAL(14,6) NOT NULL DEFAULT 0.000000 AFTER ai_total_tokens');
|
||||
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'ai_budget_exhausted', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER ai_estimated_cost_usd');
|
||||
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', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
||||
|
||||
@@ -197,7 +197,20 @@ class xlvaskUsageLogsRoute
|
||||
}
|
||||
|
||||
$input = [];
|
||||
foreach (['ids', 'dateFrom', 'dateTo', 'limit', 'forceRefetch', 'mode', 'idempotency_key'] as $key) {
|
||||
foreach ([
|
||||
'ids',
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
'limit',
|
||||
'forceRefetch',
|
||||
'mode',
|
||||
'idempotency_key',
|
||||
'aiTimeline',
|
||||
'aiBatchSize',
|
||||
'aiMaxCostUsd',
|
||||
'aiInputUsdPer1mUsd',
|
||||
'aiOutputUsdPer1mUsd',
|
||||
] as $key) {
|
||||
if ($this->isParametersSet([$key])) {
|
||||
$input[$key] = $this->getParameter($key);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ it('uses the existing OpenAI module with strict no-retention planner settings',
|
||||
->toContain("'role' => 'user'")
|
||||
->toContain("if (\$status !== 'completed')")
|
||||
->toContain("=== 'refusal'")
|
||||
->toContain("'_openai_usage' => [")
|
||||
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
|
||||
->toContain('candidate_order_id')
|
||||
->not->toContain('opaque_context_id')
|
||||
@@ -273,6 +274,9 @@ it('uses a dedicated queued XL Vask autopilot service with scoped durable runs',
|
||||
->toContain('public function getRun(')
|
||||
->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)')
|
||||
->toContain('scope_hall_ids_json')
|
||||
->toContain('ai_timeline')
|
||||
->toContain('ai_batch_size')
|
||||
->toContain('ai_max_cost_usd')
|
||||
->toContain('lease_expires_at')
|
||||
->toContain('attempt_count')
|
||||
->toContain('next_attempt_at')
|
||||
@@ -394,7 +398,16 @@ it('accepts only completed structured OpenAI responses and records the resolved
|
||||
]],
|
||||
]],
|
||||
]);
|
||||
expect($parsed)->toBe(['action' => 'none', '_openai_response_model' => 'gpt-5.6-sol']);
|
||||
expect($parsed)->toBe([
|
||||
'action' => 'none',
|
||||
'_openai_response_model' => 'gpt-5.6-sol',
|
||||
'_openai_usage' => [
|
||||
'input_tokens' => 0,
|
||||
'output_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'service_tier' => '',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails incomplete and refusal OpenAI responses closed', function (): void {
|
||||
|
||||
@@ -106,6 +106,9 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi
|
||||
->toContain('(new xlvask_autopilot_service())->createRun(')
|
||||
->toContain('(new xlvask_autopilot_service())->getRun(')
|
||||
->toContain('$this->allowedHallIdsForUser($user)')
|
||||
->toContain("'aiTimeline'")
|
||||
->toContain("'aiBatchSize'")
|
||||
->toContain("'aiMaxCostUsd'")
|
||||
->toContain('], 202);');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user