feat(api): support manual XL Vask operator decisions via force_manual (#356)

Adds a deterministic manual-suggestion path so operators can drive
accept/reject/ignore decisions on the self-wash view before the AI
autopilot has produced a suggestion. Whitelists force_manual in the
preview route. Adds unit tests for the new constant, method, and route
contract.

---------

Co-authored-by: Cleanup Agent <agent@truckwash.io>
This commit is contained in:
Jeppe B
2026-08-09 21:48:03 +02:00
committed by GitHub
co-authored by Cleanup Agent
parent 2cf2538525
commit 3e89085296
4 changed files with 126 additions and 2 deletions
@@ -27,6 +27,7 @@ class xlvask_automation_service
private const SOURCE_FUZZY = 'fuzzy';
private const SOURCE_HISTORY = 'history';
private const SOURCE_OPENAI = 'openai';
public const SOURCE_MANUAL = 'manual';
private const AUTOMATION_CASHIER_ID = 2285;
private const MIN_SUGGESTION_CONFIDENCE = 0.70;
@@ -1420,6 +1421,76 @@ class xlvask_automation_service
}
}
/**
* Create a manual operator suggestion so the standard decision preview/apply
* pipeline can run without requiring an AI autopilot suggestion. The proposed
* order and candidate scan are deterministic (driven by simulateOrderFromXLVask +
* findSameDayCandidateOrders) so we always have a proposal to act on.
*
* @return int Suggestion id (newly inserted or updated)
*/
public function createManualSuggestion(int $usageLogId, string $action, ?int $actorId, array $allowedHallIds = []): int
{
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
$action = strtolower(trim($action));
if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE, 'ignore'], true)) {
throw new Exception('Invalid manual XL Vask action.');
}
global $db;
$rowResult = $db->query(
'SELECT * FROM xlvask_usage_logs WHERE id = ' . (int)$usageLogId
. ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') LIMIT 1'
);
$row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null;
if ($row === null) {
throw new Exception('XL Vask usage log not found or not in scope.');
}
if ((string)($row['import_state'] ?? '') === 'invalid') {
throw new Exception('XL Vask usage log has invalid source data.');
}
if (in_array((string)($row['resolution_state'] ?? ''), ['auto_created', 'auto_linked', 'reviewed'], true)) {
throw new Exception('XL Vask usage log is already resolved.');
}
$log = $this->usageLogFromRow($row);
$context = $this->buildContext($usageLogId, $log, $row);
$suggestion = [
'action' => $action,
'confidence' => 0.0,
'source' => self::SOURCE_MANUAL,
'certainty' => 'manual',
'evidence' => [['kind' => 'manual_review', 'detail' => 'Operator triggered review action without an AI suggestion.']],
'contradictions' => [],
'risk_flags' => [],
'plan_steps' => [],
'matched_order_id' => $action === self::ACTION_ATTACH
? ($this->pickFirstCandidate($context)['id'] ?? null)
: null,
'proposed_order' => $context['proposed_order'] ?? [],
'candidate_order' => $action === self::ACTION_ATTACH
? ($this->pickFirstCandidate($context) ?: null)
: null,
'reason' => 'Manual operator review.',
];
$this->runId = null;
$suggestionId = $this->persistSuggestion($context, $suggestion, $actorId);
$this->runId = null;
return $suggestionId;
}
private function pickFirstCandidate(array $context): ?array
{
$candidates = is_array($context['candidate_orders'] ?? null) ? $context['candidate_orders'] : [];
foreach ($candidates as $candidate) {
if (is_array($candidate) && isset($candidate['id'])) {
return $candidate;
}
}
return null;
}
private function executeSuggestionWithinTransaction(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{
global $db;
@@ -904,7 +904,37 @@ class xlvask_autopilot_service
$suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0
? $db->fetch_assoc($suggestionResult) : null;
if ($suggestion === null) {
throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion.");
// Allow the operator to act on a wash that the AI autopilot has
// not yet scored. The manual suggestion is a deterministic
// proposal derived from the wash log itself.
$forceManual = (bool)($input['force_manual'] ?? false);
if ($forceManual) {
// Map the high-level review action to the action stored on
// the suggestion row. "ignore" is a no-op suggestion; the
// remaining actions become create_order / attach_order.
$manualSuggestionAction = match ($action) {
'attach_order' => 'attach_order',
'create_order', 'accept' => 'create_order',
'deny', 'ignore' => 'ignore',
default => 'ignore',
};
$automationService = new xlvask_automation_service();
$suggestionId = $automationService->createManualSuggestion(
$usageId,
$manualSuggestionAction,
$actorId,
$this->normalizeHallIds($allowedHallIds)
);
$suggestion = [
'id' => $suggestionId,
'usage_log_id' => $usageId,
'action' => $manualSuggestionAction,
'matched_order_id' => null,
'source' => 'manual',
];
} else {
throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion. Pass force_manual to act on it without an autopilot suggestion.");
}
}
if (isset($input['suggestion_id']) && count($rows) === 1 && (int)$suggestion['id'] !== (int)$input['suggestion_id']) {
throw new Exception('The selected XL Vask suggestion is stale.');
@@ -432,7 +432,7 @@ class xlvaskUsageLogsRoute
$response->error('Invalid session', 400);
}
$input = [];
foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason'] as $key) {
foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
@@ -780,3 +780,26 @@ it('does not score an order with only the primary product as a matching add-on a
expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence'])
->toBe(0.0);
});
it('creates a manual operator suggestion with a deterministic proposal', function (): void {
$service = new xlvask_automation_service();
$reflection = new ReflectionClass($service);
// The constant is private but must be 'manual'.
$source = $reflection->getConstant('SOURCE_MANUAL');
expect($source)->toBe('manual');
// The method must exist and be invokable on partial inputs (no AI).
expect($reflection->hasMethod('createManualSuggestion'))->toBeTrue();
// Calling it with an invalid action should throw, not silently accept.
expect(fn () => $service->createManualSuggestion(0, 'not_a_real_action', null, []))
->toThrow(Exception::class);
});
it('accepts force_manual in the decision preview contract', function (): void {
// Read the route to confirm the preview endpoint whitelists force_manual.
$routeFile = file_get_contents(__DIR__ . '/../../../routes/xlvaskUsageLogsRoute.php');
expect($routeFile)->toContain("'force_manual'");
expect($routeFile)->toContain("'usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'");
});