From 060f5abb66a64c43d8a783713f67bfcd9daf5051 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 26 Apr 2026 00:48:13 +0100 Subject: [PATCH] feat(agent/php): ProfileSelector + ShapeClassifier services per Phase 3 (#826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShapeClassifier::classify($ticket) returns 'A'|'B'|'C' per policy v1: - (severity=critical OR priority=urgent) → A - has tag in ['security','crypto','core'] → A - (severity=major OR priority=high) → B - everything else → C ProfileSelector::pickFor($ticket) walks AgentProfile::active(), matches capability tags case-insensitively against ticket.tags: - Class A: cheapest matching profile (cost_class alphabetic order) - Class B: any active profile with quota_headroom_pct >= 25 - Class C: deterministic round-robin via last_dispatched_at Pest Unit tests cover Good (matching profile picked), Bad (no match → null), Ugly (all profiles disabled → null), plus class A/B headroom gating + class C round-robin determinism. Codex note: php -l clean; pest skipped — no vendor/ at this repo root (downstream lab/lthn.ai owns composer install). Closes tasks.lthn.sh/view.php?id=826 Co-authored-by: Codex --- php/Services/ProfileSelector.php | 208 +++++++++++++++++++++++++ php/Services/ShapeClassifier.php | 113 ++++++++++++++ php/tests/Unit/ProfileSelectorTest.php | 194 +++++++++++++++++++++++ php/tests/Unit/ShapeClassifierTest.php | 57 +++++++ 4 files changed, 572 insertions(+) create mode 100644 php/Services/ProfileSelector.php create mode 100644 php/Services/ShapeClassifier.php create mode 100644 php/tests/Unit/ProfileSelectorTest.php create mode 100644 php/tests/Unit/ShapeClassifierTest.php diff --git a/php/Services/ProfileSelector.php b/php/Services/ProfileSelector.php new file mode 100644 index 0000000..aade841 --- /dev/null +++ b/php/Services/ProfileSelector.php @@ -0,0 +1,208 @@ +shapeClassifier = $shapeClassifier ?? new ShapeClassifier; + } + + /** + * @param array|object $ticket + */ + public function pickFor(mixed $ticket): ?AgentProfile + { + return match ($this->shapeClassifier->classify($ticket)) { + ShapeClassifier::CLASS_A => $this->pickClassA($ticket), + ShapeClassifier::CLASS_B => $this->pickClassB($ticket), + default => $this->pickClassC(), + }; + } + + /** + * @param array|object $ticket + */ + private function pickClassA(mixed $ticket): ?AgentProfile + { + $requiredCapabilities = $this->requiredCapabilities($ticket); + if ($requiredCapabilities === []) { + return null; + } + + $profiles = AgentProfile::query() + ->active() + ->orderBy('cost_class') + ->orderBy('id') + ->get(); + + foreach ($profiles as $profile) { + if ($this->matchesRequiredCapability($profile, $requiredCapabilities)) { + $this->recordDispatch($profile); + + return $profile->fresh(); + } + } + + return null; + } + + /** + * @param array|object $ticket + */ + private function pickClassB(mixed $ticket): ?AgentProfile + { + $requiredCapabilities = $this->requiredCapabilities($ticket); + if ($requiredCapabilities === []) { + return null; + } + + $profiles = AgentProfile::query() + ->active() + ->where('quota_headroom_pct', '>=', 25) + ->orderByRaw('CASE WHEN last_dispatched_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('last_dispatched_at') + ->orderBy('id') + ->get(); + + foreach ($profiles as $profile) { + if ($this->matchesRequiredCapability($profile, $requiredCapabilities)) { + $this->recordDispatch($profile); + + return $profile->fresh(); + } + } + + return null; + } + + private function pickClassC(): ?AgentProfile + { + return DB::transaction(function (): ?AgentProfile { + $profiles = AgentProfile::query() + ->active() + ->orderByRaw('CASE WHEN last_dispatched_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('last_dispatched_at') + ->orderBy('id') + ->lockForUpdate() + ->get(); + + $profile = $profiles->first(); + if (! $profile instanceof AgentProfile) { + return null; + } + + $this->recordDispatch($profile, $profiles); + + return $profile->fresh(); + }); + } + + /** + * @param list $requiredCapabilities + */ + private function matchesRequiredCapability(AgentProfile $profile, array $requiredCapabilities): bool + { + $profileCapabilities = $this->normaliseTags($profile->capability_tags); + + return array_intersect($requiredCapabilities, $profileCapabilities) !== []; + } + + /** + * @param array|object $ticket + * @return list + */ + private function requiredCapabilities(mixed $ticket): array + { + return $this->normaliseTags($this->value($ticket, 'tags')); + } + + /** + * @return list + */ + private function normaliseTags(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + $normalised = []; + + foreach ($tags as $key => $value) { + if (is_string($key) && $value) { + $tag = strtolower(trim($key)); + if ($tag !== '') { + $normalised[] = $tag; + } + } + + if (is_string($value)) { + $tag = strtolower(trim($value)); + if ($tag !== '') { + $normalised[] = $tag; + } + } + } + + return array_values(array_unique($normalised)); + } + + /** + * @param array|object $ticket + */ + private function value(mixed $ticket, string $key): mixed + { + if (is_array($ticket)) { + return $ticket[$key] ?? null; + } + + if ($ticket instanceof ArrayAccess && $ticket->offsetExists($key)) { + return $ticket[$key]; + } + + if (is_object($ticket) && property_exists($ticket, $key)) { + return $ticket->{$key}; + } + + return null; + } + + private function recordDispatch(AgentProfile $profile, ?Collection $activeProfiles = null): void + { + $profile->forceFill([ + 'last_dispatched_at' => $this->dispatchTimestamp($activeProfiles), + ])->save(); + } + + private function dispatchTimestamp(?Collection $activeProfiles = null): Carbon + { + $dispatchAt = now(); + + $latestDispatch = $activeProfiles + ? $activeProfiles + ->pluck('last_dispatched_at') + ->filter(static fn (mixed $value): bool => $value instanceof Carbon) + ->sortBy(static fn (Carbon $value): int => $value->getTimestamp()) + ->last() + : null; + + if ($latestDispatch instanceof Carbon && $latestDispatch->greaterThanOrEqualTo($dispatchAt)) { + return $latestDispatch->copy()->addSecond(); + } + + return $dispatchAt; + } +} diff --git a/php/Services/ShapeClassifier.php b/php/Services/ShapeClassifier.php new file mode 100644 index 0000000..40e7b39 --- /dev/null +++ b/php/Services/ShapeClassifier.php @@ -0,0 +1,113 @@ + + */ + private const ESCALATION_CAPABILITIES = [ + 'security', + 'crypto', + 'core', + ]; + + /** + * @param array|object $ticket + * @return 'A'|'B'|'C' + */ + public function classify(mixed $ticket): string + { + $severity = $this->stringValue($ticket, 'severity'); + $priority = $this->stringValue($ticket, 'priority'); + + if ($severity === 'critical' || $priority === 'urgent') { + return self::CLASS_A; + } + + if (array_intersect($this->normaliseTags($this->value($ticket, 'tags')), self::ESCALATION_CAPABILITIES) !== []) { + return self::CLASS_A; + } + + if ($severity === 'major' || $priority === 'high') { + return self::CLASS_B; + } + + return self::CLASS_C; + } + + /** + * @param array|object $ticket + */ + private function stringValue(mixed $ticket, string $key): string + { + $value = $this->value($ticket, $key); + + return is_string($value) + ? strtolower(trim($value)) + : ''; + } + + /** + * @param array|object $ticket + */ + private function value(mixed $ticket, string $key): mixed + { + if (is_array($ticket)) { + return $ticket[$key] ?? null; + } + + if ($ticket instanceof ArrayAccess && $ticket->offsetExists($key)) { + return $ticket[$key]; + } + + if (is_object($ticket) && property_exists($ticket, $key)) { + return $ticket->{$key}; + } + + return null; + } + + /** + * @return list + */ + private function normaliseTags(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + $normalised = []; + + foreach ($tags as $key => $value) { + if (is_string($key) && $value) { + $tag = strtolower(trim($key)); + if ($tag !== '') { + $normalised[] = $tag; + } + } + + if (is_string($value)) { + $tag = strtolower(trim($value)); + if ($tag !== '') { + $normalised[] = $tag; + } + } + } + + return array_values(array_unique($normalised)); + } +} diff --git a/php/tests/Unit/ProfileSelectorTest.php b/php/tests/Unit/ProfileSelectorTest.php new file mode 100644 index 0000000..8dc44f6 --- /dev/null +++ b/php/tests/Unit/ProfileSelectorTest.php @@ -0,0 +1,194 @@ + 'base64:'.base64_encode(random_bytes(32))]); + } + + Carbon::setTestNow('2026-04-26 12:00:00'); +}); + +afterEach(function (): void { + Carbon::setTestNow(); +}); + +function seedAgentProfile(array $overrides = []): AgentProfile +{ + static $sequence = 0; + + $sequence++; + + return AgentProfile::create(array_merge([ + 'name' => "profile-{$sequence}", + 'gateway_url' => "https://gateway-{$sequence}.example.com", + 'api_key_cipher' => "secret-{$sequence}", + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + 'quota_headroom_pct' => 100, + 'enabled' => true, + 'last_dispatched_at' => null, + ], $overrides)); +} + +test('ProfileSelector_pickFor_Good_picks_the_cheapest_class_A_profile_with_matching_capability', function (): void { + $selector = new ProfileSelector; + + $cheapestMatch = seedAgentProfile([ + 'name' => 'security-a', + 'cost_class' => 'A', + 'capability_tags' => ['security'], + ]); + + seedAgentProfile([ + 'name' => 'security-b', + 'cost_class' => 'B', + 'capability_tags' => ['security'], + ]); + + seedAgentProfile([ + 'name' => 'dispatch-a', + 'cost_class' => 'A', + 'capability_tags' => ['dispatch'], + ]); + + $picked = $selector->pickFor([ + 'id' => 826, + 'severity' => 'critical', + 'priority' => 'normal', + 'project' => 'mantis', + 'category' => 'security', + 'summary' => 'Triage a critical security issue', + 'tags' => ['security'], + ]); + + expect($picked?->id)->toBe($cheapestMatch->id) + ->and($picked?->last_dispatched_at)->not->toBeNull(); +}); + +test('ProfileSelector_pickFor_Good_requires_headroom_for_class_B_matches', function (): void { + $selector = new ProfileSelector; + + seedAgentProfile([ + 'name' => 'review-low-headroom', + 'capability_tags' => ['review'], + 'quota_headroom_pct' => 20, + ]); + + $eligible = seedAgentProfile([ + 'name' => 'review-eligible', + 'capability_tags' => ['review'], + 'quota_headroom_pct' => 25, + ]); + + seedAgentProfile([ + 'name' => 'review-disabled', + 'capability_tags' => ['review'], + 'quota_headroom_pct' => 90, + 'enabled' => false, + ]); + + $picked = $selector->pickFor([ + 'id' => 827, + 'severity' => 'major', + 'priority' => 'normal', + 'project' => 'mantis', + 'category' => 'review', + 'summary' => 'High-impact review task', + 'tags' => ['review'], + ]); + + expect($picked?->id)->toBe($eligible->id); +}); + +test('ProfileSelector_pickFor_Good_round_robins_class_C_profiles_deterministically', function (): void { + $selector = new ProfileSelector; + + $first = seedAgentProfile(['name' => 'round-robin-1']); + $second = seedAgentProfile(['name' => 'round-robin-2']); + $third = seedAgentProfile(['name' => 'round-robin-3']); + + $ticket = [ + 'id' => 828, + 'severity' => 'minor', + 'priority' => 'normal', + 'project' => 'mantis', + 'category' => 'general', + 'summary' => 'General maintenance', + 'tags' => [], + ]; + + $pickedIds = [ + $selector->pickFor($ticket)?->id, + $selector->pickFor($ticket)?->id, + $selector->pickFor($ticket)?->id, + $selector->pickFor($ticket)?->id, + ]; + + expect($pickedIds)->toBe([ + $first->id, + $second->id, + $third->id, + $first->id, + ]); +}); + +test('ProfileSelector_pickFor_Bad_returns_null_when_no_matching_capability_exists', function (): void { + $selector = new ProfileSelector; + + seedAgentProfile([ + 'name' => 'dispatch-only', + 'cost_class' => 'A', + 'capability_tags' => ['dispatch'], + ]); + + $picked = $selector->pickFor([ + 'id' => 829, + 'severity' => 'critical', + 'priority' => 'normal', + 'project' => 'mantis', + 'category' => 'crypto', + 'summary' => 'Needs crypto specialist', + 'tags' => ['crypto'], + ]); + + expect($picked)->toBeNull(); +}); + +test('ProfileSelector_pickFor_Ugly_returns_null_when_all_profiles_are_disabled', function (): void { + $selector = new ProfileSelector; + + seedAgentProfile([ + 'name' => 'disabled-one', + 'enabled' => false, + 'capability_tags' => ['dispatch'], + ]); + + seedAgentProfile([ + 'name' => 'disabled-two', + 'enabled' => false, + 'capability_tags' => ['review'], + ]); + + $picked = $selector->pickFor([ + 'id' => 830, + 'severity' => 'minor', + 'priority' => 'normal', + 'project' => 'mantis', + 'category' => 'general', + 'summary' => 'No active profiles left', + 'tags' => [], + ]); + + expect($picked)->toBeNull(); +}); diff --git a/php/tests/Unit/ShapeClassifierTest.php b/php/tests/Unit/ShapeClassifierTest.php new file mode 100644 index 0000000..4eb6255 --- /dev/null +++ b/php/tests/Unit/ShapeClassifierTest.php @@ -0,0 +1,57 @@ +classify([ + 'severity' => 'critical', + 'priority' => 'normal', + 'tags' => [], + ]))->toBe(ShapeClassifier::CLASS_A) + ->and($classifier->classify((object) [ + 'severity' => 'minor', + 'priority' => 'urgent', + 'tags' => [], + ]))->toBe(ShapeClassifier::CLASS_A) + ->and($classifier->classify([ + 'severity' => 'minor', + 'priority' => 'low', + 'tags' => ['security', 'bugfix'], + ]))->toBe(ShapeClassifier::CLASS_A); +}); + +test('ShapeClassifier_classify_Bad_returns_B_for_major_or_high_without_A_signals', function (): void { + $classifier = new ShapeClassifier; + + expect($classifier->classify([ + 'severity' => 'major', + 'priority' => 'normal', + 'tags' => ['dispatch'], + ]))->toBe(ShapeClassifier::CLASS_B) + ->and($classifier->classify((object) [ + 'severity' => 'minor', + 'priority' => 'high', + 'tags' => ['review'], + ]))->toBe(ShapeClassifier::CLASS_B); +}); + +test('ShapeClassifier_classify_Ugly_returns_C_when_no_escalation_signals_exist', function (): void { + $classifier = new ShapeClassifier; + + expect($classifier->classify([ + 'severity' => 'minor', + 'priority' => 'normal', + 'tags' => ['dispatch'], + ]))->toBe(ShapeClassifier::CLASS_C) + ->and($classifier->classify([ + 'severity' => null, + 'priority' => null, + 'tags' => [], + ]))->toBe(ShapeClassifier::CLASS_C); +});