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
318 lines
11 KiB
PHP
318 lines
11 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Agentic\Jobs\EmbedMemory;
|
|
use Core\Mod\Agentic\Models\BrainMemory;
|
|
use Core\Mod\Agentic\Services\BrainService;
|
|
use Illuminate\Http\Client\Request as ClientRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
function rememberValidationBrainService(): BrainService
|
|
{
|
|
return new BrainService(
|
|
ollamaUrl: 'https://ollama.test',
|
|
qdrantUrl: 'https://qdrant.test',
|
|
collection: 'openbrain',
|
|
embeddingModel: 'embeddinggemma',
|
|
verifySsl: false,
|
|
elasticsearchUrl: 'https://elasticsearch.test',
|
|
);
|
|
}
|
|
|
|
function rememberValidationAttributes(array $attributes = []): array
|
|
{
|
|
return array_merge([
|
|
'workspace_id' => $attributes['workspace_id'] ?? createWorkspace()->id,
|
|
'agent_id' => 'virgil',
|
|
'type' => 'observation',
|
|
'content' => 'Brain validation test memory.',
|
|
'tags' => ['brain', 'validation'],
|
|
'confidence' => 0.9,
|
|
'org' => 'core',
|
|
'project' => 'agent',
|
|
], $attributes);
|
|
}
|
|
|
|
function rememberValidationMemory(array $attributes = []): BrainMemory
|
|
{
|
|
return BrainMemory::create(rememberValidationAttributes($attributes));
|
|
}
|
|
|
|
test('BrainRememberValidation_remember_Good_accepts_valid_content_and_tags', function (): void {
|
|
Queue::fake();
|
|
|
|
$memory = rememberValidationBrainService()->remember(rememberValidationAttributes([
|
|
'content' => 'hello world',
|
|
'tags' => ['a', 'b'],
|
|
]));
|
|
|
|
expect($memory->exists)->toBeTrue()
|
|
->and($memory->content)->toBe('hello world')
|
|
->and($memory->tags)->toBe(['a', 'b']);
|
|
|
|
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
|
|
});
|
|
|
|
test('BrainRememberValidation_remember_Bad_rejects_content_longer_than_65536_bytes', function (): void {
|
|
Queue::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
|
'content' => str_repeat('a', 65537),
|
|
])))->toThrow(\InvalidArgumentException::class, 'content exceeds maximum length of 65536 bytes');
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('BrainRememberValidation_remember_Bad_rejects_more_than_100_tags', function (): void {
|
|
Queue::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
|
'tags' => array_fill(0, 101, 'x'),
|
|
])))->toThrow(\InvalidArgumentException::class, 'tags array exceeds maximum size of 100');
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('BrainRememberValidation_remember_Ugly_rejects_tags_longer_than_128_bytes', function (): void {
|
|
Queue::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->remember(rememberValidationAttributes([
|
|
'tags' => ['valid', str_repeat('x', 129)],
|
|
])))->toThrow(\InvalidArgumentException::class, 'tag at index 1 exceeds maximum length of 128');
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
test('BrainRememberValidation_remember_Good_accepts_content_at_exactly_65536_bytes', function (): void {
|
|
Queue::fake();
|
|
|
|
$content = str_repeat('a', 65536);
|
|
$memory = rememberValidationBrainService()->remember(rememberValidationAttributes([
|
|
'content' => $content,
|
|
'tags' => ['boundary'],
|
|
]));
|
|
|
|
expect($memory->exists)->toBeTrue()
|
|
->and($memory->content)->toBe($content);
|
|
|
|
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
|
|
});
|
|
|
|
test('BrainRememberValidation_recall_Bad_rejects_min_confidence_above_one', function (): void {
|
|
expect(fn () => rememberValidationBrainService()->recall(
|
|
'brain validation query',
|
|
5,
|
|
['min_confidence' => 1.1],
|
|
createWorkspace()->id,
|
|
))->toThrow(\InvalidArgumentException::class, 'min_confidence must be between 0.0 and 1.0');
|
|
});
|
|
|
|
test('BrainRememberValidation_recall_Bad_rejects_project_filters_longer_than_128_characters', function (): void {
|
|
Http::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->recall(
|
|
'brain validation query',
|
|
5,
|
|
['project' => str_repeat('x', 129)],
|
|
createWorkspace()->id,
|
|
))->toThrow(\InvalidArgumentException::class, 'project exceeds maximum length of 128');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('BrainRememberValidation_recall_Bad_rejects_agent_id_filters_longer_than_64_characters', function (): void {
|
|
Http::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->recall(
|
|
'brain validation query',
|
|
5,
|
|
['agent_id' => str_repeat('x', 65)],
|
|
createWorkspace()->id,
|
|
))->toThrow(\InvalidArgumentException::class, 'agent_id exceeds maximum length of 64');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('BrainRememberValidation_recall_Good_accepts_project_filters_within_bounds', function (): void {
|
|
$workspace = createWorkspace();
|
|
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 = rememberValidationBrainService()->recall(
|
|
'brain validation query',
|
|
5,
|
|
['project' => 'core'],
|
|
$workspace->id,
|
|
);
|
|
|
|
expect($result)->toBe([
|
|
'memories' => [],
|
|
'scores' => [],
|
|
]);
|
|
|
|
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' => 'project', 'match' => ['value' => 'core']],
|
|
]);
|
|
});
|
|
|
|
test('BrainRememberValidation_recall_Ugly_accepts_project_filters_at_the_128_character_boundary', function (): void {
|
|
$workspace = createWorkspace();
|
|
$project = str_repeat('x', 128);
|
|
|
|
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 = rememberValidationBrainService()->recall(
|
|
'brain validation query',
|
|
5,
|
|
['project' => $project],
|
|
$workspace->id,
|
|
);
|
|
|
|
expect($result)->toBe([
|
|
'memories' => [],
|
|
'scores' => [],
|
|
]);
|
|
|
|
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' => 'project', 'match' => ['value' => $project]],
|
|
]);
|
|
});
|
|
|
|
test('BrainRememberValidation_search_Bad_rejects_queries_longer_than_2000_bytes', function (): void {
|
|
Http::fake();
|
|
|
|
expect(fn () => rememberValidationBrainService()->search(
|
|
str_repeat('q', 2001),
|
|
createWorkspace()->id,
|
|
))->toThrow(\InvalidArgumentException::class, 'query exceeds maximum length of 2000');
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
test('BrainRememberValidation_search_Good_falls_back_to_mariadb_when_elasticsearch_fails', function (): void {
|
|
$workspace = createWorkspace();
|
|
$matching = rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'Fallback search keeps MariaDB discovery available to self-hosters.',
|
|
'project' => 'agent',
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'content' => 'Other project memory should not match the scoped fallback search.',
|
|
'project' => 'other-project',
|
|
]);
|
|
|
|
Http::fake([
|
|
'https://elasticsearch.test/brain_memories/_search' => Http::response(['error' => 'unavailable'], 503),
|
|
]);
|
|
|
|
$result = rememberValidationBrainService()->search(
|
|
'Fallback search',
|
|
$workspace->id,
|
|
['project' => 'agent'],
|
|
5,
|
|
);
|
|
|
|
expect($result)->toHaveCount(1)
|
|
->and($result[0]['id'])->toBe($matching->id)
|
|
->and($result[0]['score'])->toBe(0.0)
|
|
->and($result[0]['project'])->toBe('agent');
|
|
|
|
Http::assertSent(fn (ClientRequest $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
|
|
&& $request->method() === 'POST');
|
|
});
|
|
|
|
test('BrainRememberValidation_discoverTags_Bad_rejects_limits_above_100', function (): void {
|
|
expect(fn () => rememberValidationBrainService()->discoverTags(
|
|
createWorkspace()->id,
|
|
limit: 101,
|
|
))->toThrow(\InvalidArgumentException::class, 'limit must be between 1 and 100');
|
|
});
|
|
|
|
test('BrainRememberValidation_discoverTags_Good_counts_tags_within_scope_and_ignores_blank_tags', function (): void {
|
|
$workspace = createWorkspace();
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'tags' => ['openbrain', 'architecture', ' '],
|
|
'project' => 'agent',
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'tags' => ['openbrain'],
|
|
'project' => 'agent',
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'tags' => ['deploy'],
|
|
'project' => 'other-project',
|
|
]);
|
|
|
|
$result = rememberValidationBrainService()->discoverTags($workspace->id, 'core', 'agent', 2);
|
|
|
|
expect($result)->toBe([
|
|
['name' => 'openbrain', 'count' => 2],
|
|
['name' => 'architecture', 'count' => 1],
|
|
]);
|
|
});
|
|
|
|
test('BrainRememberValidation_listScopes_Ugly_returns_sorted_scope_counts', function (): void {
|
|
$workspace = createWorkspace();
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'org' => 'core',
|
|
'project' => 'host',
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'org' => 'core',
|
|
'project' => 'agent',
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => $workspace->id,
|
|
'org' => null,
|
|
'project' => null,
|
|
]);
|
|
rememberValidationMemory([
|
|
'workspace_id' => createWorkspace()->id,
|
|
'org' => 'ops',
|
|
'project' => 'deploy',
|
|
]);
|
|
|
|
$result = rememberValidationBrainService()->listScopes($workspace->id);
|
|
|
|
expect($result)->toBe([
|
|
[
|
|
'org' => null,
|
|
'count' => 1,
|
|
'projects' => [],
|
|
],
|
|
[
|
|
'org' => 'core',
|
|
'count' => 2,
|
|
'projects' => [
|
|
['name' => 'agent', 'count' => 1],
|
|
['name' => 'host', 'count' => 1],
|
|
],
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('BrainRememberValidation_forget_Bad_rejects_ids_longer_than_64_characters', function (): void {
|
|
expect(fn () => rememberValidationBrainService()->forget(str_repeat('x', 65)))
|
|
->toThrow(\InvalidArgumentException::class, 'id exceeds maximum length of 64');
|
|
});
|