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:
parent
6da45637f5
commit
a13f4c4bbd
4 changed files with 358 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
230
php/tests/Feature/Api/BrainTagsScopesTest.php
Normal file
230
php/tests/Feature/Api/BrainTagsScopesTest.php
Normal 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,
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue