From 296ed59b1b0a40dd14f4931178b718b9326e2fde Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 13:42:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(brain):=20add=20GET=20/v1/brain/search=20?= =?UTF-8?q?=E2=80=94=20ES=20full-text=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoint GET /v1/brain/search?q=&org=<>&project=<>&limit= that full-text-searches brain_memories via Elasticsearch using BrainService::elasticSearch(). Separate from /v1/brain/recall (which is vector/semantic via Qdrant) — this one is keyword/lexical. Sits under the existing brain.read-auth middleware group. Pest coverage (Http::fake for ES): Good (matches), Bad (invalid limit), Ugly (empty query + filters). Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=64 --- php/Controllers/Api/BrainController.php | 114 ++++++++++++++++++ php/Routes/api.php | 3 + php/tests/Feature/Api/BrainSearchTest.php | 138 ++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 php/tests/Feature/Api/BrainSearchTest.php diff --git a/php/Controllers/Api/BrainController.php b/php/Controllers/Api/BrainController.php index 67a5a64..b268341 100644 --- a/php/Controllers/Api/BrainController.php +++ b/php/Controllers/Api/BrainController.php @@ -1,5 +1,7 @@ validate([ + 'q' => 'required|string|max:2000', + 'org' => 'nullable|string|max:255', + 'project' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:100', + ]); + + $workspace = $request->attributes->get('workspace'); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); + $limit = min(max((int) ($validated['limit'] ?? 20), 1), 100); + + $filters = [ + 'workspace_id' => $workspaceId, + ]; + + foreach (['org', 'project'] as $field) { + if (isset($validated[$field]) && $validated[$field] !== '') { + $filters[$field] = $validated[$field]; + } + } + + try { + $brain = app(BrainService::class); + $result = $brain->elasticSearch($validated['q'], $filters); + $hits = $result['hits']['hits'] ?? []; + + if (! is_array($hits)) { + $hits = []; + } + + $hitData = $this->normaliseSearchHits(array_slice($hits, 0, $limit)); + + if ($hitData['ids'] === []) { + return response()->json([ + 'data' => [ + 'memories' => [], + 'count' => 0, + ], + ]); + } + + $query = BrainMemory::whereIn('id', $hitData['ids']) + ->forWorkspace($workspaceId) + ->active() + ->latestVersions(); + + if (isset($filters['project'])) { + $query->forProject((string) $filters['project']); + } + + $memoryMap = $query->get()->keyBy('id'); + $memories = []; + + foreach ($hitData['ids'] as $id) { + $memory = $memoryMap->get($id); + + if ($memory instanceof BrainMemory) { + $memories[] = $memory->toMcpContext((float) ($hitData['scores'][$id] ?? 0.0)); + } + } + + return response()->json([ + 'data' => [ + 'memories' => $memories, + 'count' => count($memories), + ], + ]); + } catch (\RuntimeException $e) { + return response()->json([ + 'error' => 'service_error', + 'message' => 'Brain service temporarily unavailable.', + ], 503); + } + } + /** * DELETE /api/brain/forget/{id} * @@ -340,4 +424,34 @@ class BrainController extends Controller ], 503); } } + + /** + * @param array $hits + * @return array{ids: array, scores: array} + */ + private function normaliseSearchHits(array $hits): array + { + $ids = []; + $scores = []; + + foreach ($hits as $hit) { + if (! is_array($hit)) { + continue; + } + + $id = $hit['_id'] ?? ($hit['_source']['id'] ?? null); + + if (! is_string($id) || $id === '' || in_array($id, $ids, true)) { + continue; + } + + $ids[] = $id; + $scores[$id] = (float) ($hit['_score'] ?? 0.0); + } + + return [ + 'ids' => $ids, + 'scores' => $scores, + ]; + } } diff --git a/php/Routes/api.php b/php/Routes/api.php index d5c21b0..2bf5b7c 100644 --- a/php/Routes/api.php +++ b/php/Routes/api.php @@ -1,5 +1,7 @@ group(function () Route::middleware(AgentApiAuth::class.':brain.read')->group(function () { Route::post('v1/brain/recall', [BrainController::class, 'recall']); + Route::get('v1/brain/search', [BrainController::class, 'search']); Route::get('v1/brain/list', [BrainController::class, 'list']); Route::get('v1/brain/tags', [BrainController::class, 'tags']); Route::get('v1/brain/scopes', [BrainController::class, 'scopes']); diff --git a/php/tests/Feature/Api/BrainSearchTest.php b/php/tests/Feature/Api/BrainSearchTest.php new file mode 100644 index 0000000..45cb3f2 --- /dev/null +++ b/php/tests/Feature/Api/BrainSearchTest.php @@ -0,0 +1,138 @@ +app->instance(BrainService::class, new BrainService( + ollamaUrl: 'https://ollama.test', + qdrantUrl: 'https://qdrant.test', + collection: 'openbrain', + embeddingModel: 'embeddinggemma', + verifySsl: false, + elasticsearchUrl: 'https://elasticsearch.test', + )); + + require __DIR__.'/../../../Routes/api.php'; +}); + +function brainSearchMemory(Workspace $workspace, array $attributes = []): BrainMemory +{ + return BrainMemory::create(array_merge([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'architecture', + 'content' => 'Elasticsearch mirrors brain memories for lexical search.', + 'tags' => ['brain', 'search'], + 'project' => 'agent', + 'confidence' => 0.95, + ], $attributes)); +} + +function brainSearchKey(Workspace $workspace, array $permissions = [AgentApiKey::PERM_BRAIN_READ]): AgentApiKey +{ + return createApiKey($workspace, 'Brain Search Key', $permissions); +} + +test('BrainController_search_Good_returns_memories_with_elasticsearch_scores', function (): void { + $workspace = createWorkspace(); + $first = brainSearchMemory($workspace, [ + 'content' => 'Queue indexing uses Elasticsearch for keyword recall.', + ]); + $second = brainSearchMemory($workspace, [ + 'content' => 'Project filters keep search results narrow.', + ]); + $key = brainSearchKey($workspace); + + Http::fake([ + 'https://elasticsearch.test/brain_memories/_search' => Http::response([ + 'hits' => [ + 'hits' => [ + ['_id' => $second->id, '_score' => 2.75], + ['_id' => $first->id, '_score' => 1.25], + ], + ], + ]), + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/brain/search?q=queue%20indexing&org=core&project=agent'); + + $response + ->assertOk() + ->assertJsonPath('data.count', 2) + ->assertJsonPath('data.memories.0.id', $second->id) + ->assertJsonPath('data.memories.0.score', 2.75) + ->assertJsonPath('data.memories.1.id', $first->id) + ->assertJsonPath('data.memories.1.score', 1.25); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search' + && $request->method() === 'POST' + && $request['query']['bool']['must'][0]['multi_match']['query'] === 'queue indexing' + && $request['query']['bool']['filter'] === [ + ['term' => ['workspace_id' => $workspace->id]], + ['term' => ['org' => 'core']], + ['term' => ['project' => 'agent']], + ]); +}); + +test('BrainController_search_Bad_returns_service_error_when_elasticsearch_fails', function (): void { + $workspace = createWorkspace(); + $key = brainSearchKey($workspace); + + Http::fake([ + 'https://elasticsearch.test/brain_memories/_search' => Http::response(['error' => 'unavailable'], 503), + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/brain/search?q=queue%20indexing'); + + $response + ->assertStatus(503) + ->assertJsonPath('error', 'service_error') + ->assertJsonPath('message', 'Brain service temporarily unavailable.'); +}); + +test('BrainController_search_Ugly_limits_results_and_ignores_stale_hits', function (): void { + $workspace = createWorkspace(); + $otherWorkspace = createWorkspace(); + $visible = brainSearchMemory($workspace, [ + 'content' => 'Visible memory should survive stale index entries.', + ]); + $hidden = brainSearchMemory($otherWorkspace, [ + 'content' => 'Other workspace memory must not leak through ES.', + ]); + $key = brainSearchKey($workspace); + + Http::fake([ + 'https://elasticsearch.test/brain_memories/_search' => Http::response([ + 'hits' => [ + 'hits' => [ + ['_id' => 'missing-memory-id', '_score' => 9.5], + ['_id' => $hidden->id, '_score' => 8.5], + ['_id' => $visible->id, '_score' => 7.5], + ], + ], + ]), + ]); + + $response = $this + ->withHeader('Authorization', 'Bearer '.$key->plainTextKey) + ->getJson('/v1/brain/search?q=stale&limit=3'); + + $response + ->assertOk() + ->assertJsonPath('data.count', 1) + ->assertJsonPath('data.memories.0.id', $visible->id) + ->assertJsonPath('data.memories.0.score', 7.5); +});