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
This commit is contained in:
parent
a13f4c4bbd
commit
296ed59b1b
3 changed files with 255 additions and 0 deletions
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
|
@ -170,6 +172,88 @@ class BrainController extends Controller
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/brain/search
|
||||
*
|
||||
* Full-text search across memories.
|
||||
*/
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->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<int, mixed> $hits
|
||||
* @return array{ids: array<int, string>, scores: array<string, float>}
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Controllers\AgentApiController;
|
||||
|
|
@ -46,6 +48,7 @@ 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/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']);
|
||||
|
|
|
|||
138
php/tests/Feature/Api/BrainSearchTest.php
Normal file
138
php/tests/Feature/Api/BrainSearchTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue