agent/php/tests/Feature/Api/BrainSearchTest.php
Snider 296ed59b1b feat(brain): add GET /v1/brain/search — ES full-text endpoint
New endpoint GET /v1/brain/search?q=<query>&org=<>&project=<>&limit=<N>
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 <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=64
2026-04-23 13:54:11 +01:00

138 lines
4.8 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
$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',
));
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);
});