From a13f4c4bbd2a4fc0f5ce37e1a4b58c0acedd0c49 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 13:41:26 +0100 Subject: [PATCH] feat(brain): add GET /v1/brain/tags + /v1/brain/scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=65 --- php/Controllers/Api/BrainController.php | 105 ++++++++ php/Routes/api.php | 2 + php/Services/BrainService.php | 21 ++ php/tests/Feature/Api/BrainTagsScopesTest.php | 230 ++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 php/tests/Feature/Api/BrainTagsScopesTest.php diff --git a/php/Controllers/Api/BrainController.php b/php/Controllers/Api/BrainController.php index e353eb5..67a5a64 100644 --- a/php/Controllers/Api/BrainController.php +++ b/php/Controllers/Api/BrainController.php @@ -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); + } + } } diff --git a/php/Routes/api.php b/php/Routes/api.php index 3ac92f4..d5c21b0 100644 --- a/php/Routes/api.php +++ b/php/Routes/api.php @@ -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 () { diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index 2c06fdd..2f643ce 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -392,6 +392,27 @@ class BrainService return false; } + /** + * Run an Elasticsearch aggregation query against brain memories. + * + * @param array $body + * @return array + */ + 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. * diff --git a/php/tests/Feature/Api/BrainTagsScopesTest.php b/php/tests/Feature/Api/BrainTagsScopesTest.php new file mode 100644 index 0000000..a846e43 --- /dev/null +++ b/php/tests/Feature/Api/BrainTagsScopesTest.php @@ -0,0 +1,230 @@ +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, + ], + ], + ]); +});