feat(brain): add GET /v1/brain/tags + /v1/brain/scopes

Two introspection endpoints for OpenBrain:
- GET /v1/brain/tags — ES terms aggregation over tags.keyword, returns
  {tag: count} pairs for UI filter chips
- GET /v1/brain/scopes — composite aggregation over {org, project},
  returns the scope hierarchy present in the index

Sits under the existing brain.read-auth group in Routes/api.php. New
BrainService helpers for aggregation shape; reuses the elasticSearch
HTTP path added in #59.

Pest coverage with Http::fake for ES.

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=65
This commit is contained in:
Snider 2026-04-23 13:41:26 +01:00
parent 6da45637f5
commit a13f4c4bbd
4 changed files with 358 additions and 0 deletions

View file

@ -235,4 +235,109 @@ class BrainController extends Controller
], 422);
}
}
/**
* GET /v1/brain/tags
*
* List distinct memory tags and document counts.
*/
public function tags(BrainService $brain): JsonResponse
{
try {
$result = $brain->elasticAggregate([
'size' => 0,
'aggs' => [
'tags' => [
'terms' => [
'field' => 'tags.keyword',
'size' => 1000,
],
],
],
]);
$tags = [];
$buckets = $result['aggregations']['tags']['buckets'] ?? [];
if (is_array($buckets)) {
foreach ($buckets as $bucket) {
if (! is_array($bucket) || ! is_string($bucket['key'] ?? null)) {
continue;
}
$tags[$bucket['key']] = (int) ($bucket['doc_count'] ?? 0);
}
}
return response()->json([
'data' => $tags,
]);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* GET /v1/brain/scopes
*
* List distinct organisation/project memory scopes.
*/
public function scopes(BrainService $brain): JsonResponse
{
try {
$result = $brain->elasticAggregate([
'size' => 0,
'aggs' => [
'scopes' => [
'composite' => [
'size' => 1000,
'sources' => [
[
'org' => [
'terms' => [
'field' => 'org.keyword',
],
],
],
[
'project' => [
'terms' => [
'field' => 'project.keyword',
],
],
],
],
],
],
],
]);
$scopes = [];
$buckets = $result['aggregations']['scopes']['buckets'] ?? [];
if (is_array($buckets)) {
foreach ($buckets as $bucket) {
$key = is_array($bucket) ? ($bucket['key'] ?? null) : null;
if (! is_array($key) || ! is_string($key['org'] ?? null) || ! is_string($key['project'] ?? null)) {
continue;
}
$scopes[$key['org']][$key['project']] = (int) ($bucket['doc_count'] ?? 0);
}
}
return response()->json([
'data' => $scopes,
]);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
}

View file

@ -47,6 +47,8 @@ Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function ()
Route::middleware(AgentApiAuth::class.':brain.read')->group(function () {
Route::post('v1/brain/recall', [BrainController::class, 'recall']);
Route::get('v1/brain/list', [BrainController::class, 'list']);
Route::get('v1/brain/tags', [BrainController::class, 'tags']);
Route::get('v1/brain/scopes', [BrainController::class, 'scopes']);
});
Route::middleware(AgentApiAuth::class.':brain.write')->group(function () {

View file

@ -392,6 +392,27 @@ class BrainService
return false;
}
/**
* Run an Elasticsearch aggregation query against brain memories.
*
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
public function elasticAggregate(array $body): array
{
$response = $this->http(10)
->post($this->elasticSearchUrl(), $body);
if (! $response->successful()) {
Log::error("Elasticsearch aggregation failed: {$response->status()}", ['request' => $body, 'body' => $response->body()]);
throw new \RuntimeException("Elasticsearch aggregation failed: {$response->status()}");
}
$result = $response->json();
return is_array($result) ? $result : [];
}
/**
* Build a Qdrant filter from criteria.
*

View file

@ -0,0 +1,230 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
function brainTagsScopesRegisterRoutes(): void
{
require __DIR__.'/../../../Routes/api.php';
}
function brainTagsScopesKey(): string
{
$workspace = createWorkspace();
$key = createApiKey($workspace, 'Brain Reader', [AgentApiKey::PERM_BRAIN_READ], 1000);
return (string) $key->plainTextKey;
}
beforeEach(function (): void {
brainTagsScopesRegisterRoutes();
$this->app->instance(BrainService::class, new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
elasticsearchUrl: 'https://elasticsearch.test',
));
});
test('BrainController_tags_Good_returns_tag_counts_from_elasticsearch_terms_aggregation', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'aggregations' => [
'tags' => [
'buckets' => [
['key' => 'architecture', 'doc_count' => 7],
['key' => 'openbrain', 'doc_count' => 3],
],
],
],
]),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/tags');
$response
->assertOk()
->assertExactJson([
'data' => [
'architecture' => 7,
'openbrain' => 3,
],
]);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
&& $request->method() === 'POST'
&& $request['size'] === 0
&& $request['aggs'] === [
'tags' => [
'terms' => [
'field' => 'tags.keyword',
'size' => 1000,
],
],
]);
});
test('BrainController_tags_Bad_returns_service_error_when_elasticsearch_fails', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response(['error' => 'unavailable'], 503),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/tags');
$response
->assertStatus(503)
->assertExactJson([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
]);
});
test('BrainController_tags_Ugly_ignores_malformed_tag_buckets', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'aggregations' => [
'tags' => [
'buckets' => [
['key' => 'indexed', 'doc_count' => 4],
['key' => ['not-a-string'], 'doc_count' => 9],
['doc_count' => 2],
],
],
],
]),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/tags');
$response
->assertOk()
->assertExactJson([
'data' => [
'indexed' => 4,
],
]);
});
test('BrainController_scopes_Good_returns_hierarchical_scope_tree_from_composite_aggregation', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'aggregations' => [
'scopes' => [
'buckets' => [
['key' => ['org' => 'core', 'project' => 'agent'], 'doc_count' => 11],
['key' => ['org' => 'core', 'project' => 'host'], 'doc_count' => 5],
['key' => ['org' => 'ops', 'project' => 'deploy'], 'doc_count' => 2],
],
],
],
]),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/scopes');
$response
->assertOk()
->assertExactJson([
'data' => [
'core' => [
'agent' => 11,
'host' => 5,
],
'ops' => [
'deploy' => 2,
],
],
]);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
&& $request->method() === 'POST'
&& $request['size'] === 0
&& $request['aggs'] === [
'scopes' => [
'composite' => [
'size' => 1000,
'sources' => [
[
'org' => [
'terms' => [
'field' => 'org.keyword',
],
],
],
[
'project' => [
'terms' => [
'field' => 'project.keyword',
],
],
],
],
],
],
]);
});
test('BrainController_scopes_Bad_returns_service_error_when_elasticsearch_fails', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response(['error' => 'unavailable'], 500),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/scopes');
$response
->assertStatus(503)
->assertExactJson([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
]);
});
test('BrainController_scopes_Ugly_ignores_incomplete_scope_buckets', function (): void {
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'aggregations' => [
'scopes' => [
'buckets' => [
['key' => ['org' => 'core', 'project' => 'agent'], 'doc_count' => 3],
['key' => ['org' => 'core'], 'doc_count' => 8],
['key' => ['project' => 'missing-org'], 'doc_count' => 4],
['doc_count' => 1],
],
],
],
]),
]);
$response = $this
->withHeader('Authorization', 'Bearer '.brainTagsScopesKey())
->getJson('/v1/brain/scopes');
$response
->assertOk()
->assertExactJson([
'data' => [
'core' => [
'agent' => 3,
],
],
]);
});