feat(agent/php): ProfileSelector + ShapeClassifier services per Phase 3 (#826)
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 <noreply@openai.com>
This commit is contained in:
parent
8117d89287
commit
060f5abb66
4 changed files with 572 additions and 0 deletions
208
php/Services/ProfileSelector.php
Normal file
208
php/Services/ProfileSelector.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Services;
|
||||
|
||||
use ArrayAccess;
|
||||
use Core\Mod\Agentic\Models\AgentProfile;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProfileSelector
|
||||
{
|
||||
private ShapeClassifier $shapeClassifier;
|
||||
|
||||
public function __construct(?ShapeClassifier $shapeClassifier = null)
|
||||
{
|
||||
$this->shapeClassifier = $shapeClassifier ?? new ShapeClassifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|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<string, mixed>|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<string, mixed>|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<string> $requiredCapabilities
|
||||
*/
|
||||
private function matchesRequiredCapability(AgentProfile $profile, array $requiredCapabilities): bool
|
||||
{
|
||||
$profileCapabilities = $this->normaliseTags($profile->capability_tags);
|
||||
|
||||
return array_intersect($requiredCapabilities, $profileCapabilities) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|object $ticket
|
||||
* @return list<string>
|
||||
*/
|
||||
private function requiredCapabilities(mixed $ticket): array
|
||||
{
|
||||
return $this->normaliseTags($this->value($ticket, 'tags'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
113
php/Services/ShapeClassifier.php
Normal file
113
php/Services/ShapeClassifier.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Services;
|
||||
|
||||
use ArrayAccess;
|
||||
|
||||
class ShapeClassifier
|
||||
{
|
||||
public const CLASS_A = 'A';
|
||||
|
||||
public const CLASS_B = 'B';
|
||||
|
||||
public const CLASS_C = 'C';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const ESCALATION_CAPABILITIES = [
|
||||
'security',
|
||||
'crypto',
|
||||
'core',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|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<string, mixed>|object $ticket
|
||||
*/
|
||||
private function stringValue(mixed $ticket, string $key): string
|
||||
{
|
||||
$value = $this->value($ticket, $key);
|
||||
|
||||
return is_string($value)
|
||||
? strtolower(trim($value))
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|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<string>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
194
php/tests/Unit/ProfileSelectorTest.php
Normal file
194
php/tests/Unit/ProfileSelectorTest.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentProfile;
|
||||
use Core\Mod\Agentic\Services\ProfileSelector;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
if (! is_string(config('app.key')) || config('app.key') === '') {
|
||||
config(['app.key' => '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();
|
||||
});
|
||||
57
php/tests/Unit/ShapeClassifierTest.php
Normal file
57
php/tests/Unit/ShapeClassifierTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Services\ShapeClassifier;
|
||||
|
||||
test('ShapeClassifier_classify_Good_returns_A_for_critical_urgent_and_escalation_tags', function (): void {
|
||||
$classifier = new ShapeClassifier;
|
||||
|
||||
expect($classifier->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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue