agent/php/tests/Feature/Brain/OrgScopingTest.php
Snider 1872424cfd feat(agent/brain): lift OpenBrain discovery features (search/discoverTags/listScopes) (#180)
Bounded subset of RFC-OPENBRAIN lifted from lab/lthn.ai shim into the
OSS BrainService at php/Services/BrainService.php:

- search(query, filter, pagination): Elasticsearch path first, falls
  back to MariaDB if ES is unavailable. Operates on active/latest
  memories only.
- discoverTags(filter): tag-cloud / popular-tags discovery scoped to
  authenticated org(s).
- listScopes(filter): org/project distribution counts for the
  authenticated session.

All three:
- Enforce bounded inputs (per #1001 patterns)
- Honour org auth (per #312 patterns)
- Only operate on active/latest memories (active=1, deleted_at IS NULL)

Self-hosters now get the same discovery surface that lab/lthn.ai
exposes — no need to fork the OSS service to access these features.

Pest covers: bounds-violation rejection, fallback behaviour, scoped
discovery returning correct org/project breakdowns.

Lab-only features still out of scope for this lane (would pull in
extra schema/models/events): agentContext, recall feedback,
maintenance lifecycle (reindex/consolidate/clean/prune). Those need
follow-up tickets if/when bounded-lift becomes possible.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=180
2026-04-25 20:39:14 +01:00

324 lines
11 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Auth\Access\AuthorizationException;
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
use Core\Mod\Agentic\Jobs\EmbedMemory;
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 as ClientRequest;
use Illuminate\Http\Request as HttpRequest;
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));
}
function orgScopingBindRequestContext(object $workspace, array $context = []): void
{
$request = HttpRequest::create('/api/v1/mcp/tools/call', 'POST');
$request->attributes->set('workspace', $workspace);
$request->attributes->set('workspace_id', $workspace->id);
$request->attributes->set('mcp_workspace_context', array_merge([
'workspace_id' => $workspace->id,
'workspace' => $workspace,
], $context));
app()->instance('request', $request);
}
test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$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 (ClientRequest $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_recall_Bad_rejects_an_unauthorised_org_before_any_qdrant_query', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$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' => []]),
]);
expect($memory->fresh()?->getAttribute('org'))->toBe('core');
expect(fn () => $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id))
->toThrow(AuthorizationException::class, "Organisation scope 'other-org' is not authorised for this authenticated workspace.");
Http::assertNothingSent();
});
test('OrgScoping_remember_Bad_rejects_an_unauthorised_org_without_inserting_a_memory', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$brain = orgScopingBrainService();
expect(fn () => $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'This should never be stored for another organisation.',
'org' => 'evil',
'project' => 'agent',
'confidence' => 0.9,
]))->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
expect(BrainMemory::query()->count())->toBe(0);
Queue::assertNotPushed(EmbedMemory::class);
});
test('OrgScoping_forget_Bad_rejects_forgetting_a_memory_from_another_org', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$brain = orgScopingBrainService();
$memory = orgScopingMemory($workspace->id, [
'content' => 'Other org memory must not be forgotten by core.',
'org' => 'evil',
]);
expect(fn () => $brain->forget($memory->id))
->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
expect(BrainMemory::query()->find($memory->id))->not->toBeNull();
Queue::assertNotPushed(DeleteFromIndex::class);
});
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');
});
test('OrgScoping_search_Good_limits_results_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
$coreMemory = orgScopingMemory($workspace->id, [
'content' => 'Core scoped discovery memory.',
'org' => 'core',
]);
$globalMemory = orgScopingMemory($workspace->id, [
'content' => 'Global discovery memory.',
'org' => null,
'project' => null,
]);
$otherOrgMemory = orgScopingMemory($workspace->id, [
'content' => 'Other organisation discovery memory.',
'org' => 'other-org',
]);
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'hits' => [
'hits' => [
['_id' => $globalMemory->id, '_score' => 3.5],
['_id' => $otherOrgMemory->id, '_score' => 2.5],
['_id' => $coreMemory->id, '_score' => 1.5],
],
],
]),
]);
$result = $brain->search('discovery memory', $workspace->id, [], 5);
expect(array_column($result, 'id'))->toBe([
$globalMemory->id,
$coreMemory->id,
])
->and($result[0]['score'])->toBe(3.5)
->and($result[1]['score'])->toBe(1.5);
Http::assertSent(fn (ClientRequest $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
&& $request->method() === 'POST'
&& $request['query']['bool']['filter'] === [
['term' => ['workspace_id' => $workspace->id]],
]);
});
test('OrgScoping_discoverTags_Bad_rejects_an_unauthorised_org_filter', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
expect(fn () => $brain->discoverTags($workspace->id, 'other-org'))
->toThrow(AuthorizationException::class, "Organisation scope 'other-org' is not authorised for this authenticated workspace.");
});
test('OrgScoping_discoverTags_Good_limits_results_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
orgScopingMemory($workspace->id, [
'content' => 'Core tag memory.',
'org' => 'core',
'tags' => ['core-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Second core tag memory.',
'org' => 'core',
'tags' => ['core-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Global tag memory.',
'org' => null,
'project' => null,
'tags' => ['global-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Other org tag memory.',
'org' => 'other-org',
'tags' => ['other-tag'],
]);
$result = $brain->discoverTags($workspace->id);
expect($result)->toBe([
['name' => 'core-tag', 'count' => 2],
['name' => 'global-tag', 'count' => 1],
]);
});
test('OrgScoping_listScopes_Good_limits_scope_tree_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
orgScopingMemory($workspace->id, [
'content' => 'Core agent memory.',
'org' => 'core',
'project' => 'agent',
]);
orgScopingMemory($workspace->id, [
'content' => 'Global shared memory.',
'org' => null,
'project' => null,
]);
orgScopingMemory($workspace->id, [
'content' => 'Other organisation memory.',
'org' => 'other-org',
'project' => 'agent',
]);
$result = $brain->listScopes($workspace->id);
expect($result)->toBe([
[
'org' => null,
'count' => 1,
'projects' => [],
],
[
'org' => 'core',
'count' => 1,
'projects' => [
['name' => 'agent', 'count' => 1],
],
],
]);
});