agent/php/tests/Feature/Brain/OrgScopingTest.php

132 lines
4.7 KiB
PHP
Raw Normal View History

fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
function orgScopingBrainService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
elasticsearchUrl: 'https://elasticsearch.test',
);
}
function orgScopingMemory(int $workspaceId, array $attributes = []): BrainMemory
{
return BrainMemory::create(array_merge([
'workspace_id' => $workspaceId,
'agent_id' => 'virgil',
'type' => 'context',
'content' => 'Organisation-scoped OpenBrain memory.',
'confidence' => 0.85,
'org' => 'core',
'project' => 'agent',
], $attributes));
}
test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org', function (): void {
Queue::fake();
$workspace = createWorkspace();
$brain = orgScopingBrainService();
$memory = $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Core remembers its own scoped knowledge.',
'org' => 'core',
'project' => 'agent',
'confidence' => 0.92,
]);
Http::fake([
'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]),
'https://qdrant.test/collections/openbrain/points/search' => Http::response([
'result' => [
['id' => $memory->id, 'score' => 0.91],
],
]),
]);
$result = $brain->recall('core scoped knowledge', 5, ['org' => 'core'], $workspace->id);
expect($memory->fresh()?->getAttribute('org'))->toBe('core')
->and($result['memories'])->toHaveCount(1)
->and($result['memories'][0]['id'])->toBe($memory->id)
->and($result['memories'][0]['org'])->toBe('core');
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
&& $request->method() === 'POST'
&& $request['filter']['must'] === [
['key' => 'workspace_id', 'match' => ['value' => $workspace->id]],
['key' => 'org', 'match' => ['value' => 'core']],
]);
});
test('OrgScoping_remember_recall_Bad_does_not_recall_across_org_boundaries', function (): void {
Queue::fake();
$workspace = createWorkspace();
$brain = orgScopingBrainService();
$memory = $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Core-only memory should not leak into another organisation scope.',
'org' => 'core',
'project' => 'agent',
'confidence' => 0.88,
]);
Http::fake([
'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]),
'https://qdrant.test/collections/openbrain/points/search' => Http::response(['result' => []]),
]);
$result = $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id);
expect($memory->fresh()?->getAttribute('org'))->toBe('core')
->and($result['memories'])->toBe([])
->and($result['scores'])->toBe([]);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
&& $request->method() === 'POST'
&& $request['filter']['must'] === [
['key' => 'workspace_id', 'match' => ['value' => $workspace->id]],
['key' => 'org', 'match' => ['value' => 'other-org']],
]);
});
test('OrgScoping_list_Ugly_filters_memories_by_org', function (): void {
$workspace = createWorkspace();
$otherWorkspace = createWorkspace();
$coreMemory = orgScopingMemory($workspace->id, ['content' => 'Core memory', 'org' => 'core']);
orgScopingMemory($workspace->id, ['content' => 'Other org memory', 'org' => 'other-org']);
orgScopingMemory($otherWorkspace->id, ['content' => 'Other workspace memory', 'org' => 'core']);
$result = (new BrainList)->handle([
'org' => 'core',
'limit' => 10,
], [
'workspace_id' => $workspace->id,
]);
expect($result['success'])->toBeTrue()
->and($result['count'])->toBe(1)
->and($result['memories'])->toHaveCount(1)
->and($result['memories'][0]['id'])->toBe($coreMemory->id)
->and($result['memories'][0]['org'])->toBe('core');
});