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>
131 lines
4.7 KiB
PHP
131 lines
4.7 KiB
PHP
<?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');
|
|
});
|