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:
@@ -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'");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user