Fills in the elasticIndex/elasticDelete stubs added by #56 and #57, plus a new elasticSearch() method used by the upcoming /v1/brain/search endpoint (#64). - elasticIndex(BrainMemory) → PUT /brain_memories/_doc/{id} - elasticDelete(string $id) → DELETE /brain_memories/_doc/{id} - elasticSearch(string $query, array $filters) → POST /brain_memories/_search - ES URL default http://127.0.0.1:9200 (config override via BRAIN_ELASTICSEARCH_URL env var) - RuntimeException on HTTP failures (same pattern as qdrantUpsert) php/tests/Feature/Services/BrainServiceElasticTest.php covers Good/Bad/Ugly for index, delete, and search using Http::fake. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=59
195 lines
7.1 KiB
PHP
195 lines
7.1 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Agentic\Models\BrainMemory;
|
|
use Core\Mod\Agentic\Services\BrainService;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
function elasticBrainService(): BrainService
|
|
{
|
|
return new BrainService(
|
|
ollamaUrl: 'https://ollama.test',
|
|
qdrantUrl: 'https://qdrant.test',
|
|
collection: 'openbrain',
|
|
embeddingModel: 'embeddinggemma',
|
|
verifySsl: false,
|
|
elasticsearchUrl: 'https://elasticsearch.test',
|
|
);
|
|
}
|
|
|
|
function elasticBrainMemory(array $attributes = []): BrainMemory
|
|
{
|
|
$memory = new BrainMemory;
|
|
$memory->forceFill(array_merge([
|
|
'id' => Str::uuid()->toString(),
|
|
'workspace_id' => 42,
|
|
'agent_id' => 'virgil',
|
|
'type' => 'architecture',
|
|
'content' => 'Elasticsearch mirrors brain memories for lexical search.',
|
|
'tags' => ['brain', 'search'],
|
|
'project' => 'agent',
|
|
'confidence' => 0.95,
|
|
'indexed_at' => Carbon::parse('2026-04-23 12:00:00', 'UTC'),
|
|
], $attributes));
|
|
$memory->setAttribute('org', 'core');
|
|
|
|
return $memory;
|
|
}
|
|
|
|
test('BrainService_elasticIndex_Good_indexes_memory_document', function (): void {
|
|
$memory = elasticBrainMemory();
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memory->id}" => Http::response(['result' => 'updated']),
|
|
]);
|
|
|
|
elasticBrainService()->elasticIndex($memory);
|
|
|
|
Http::assertSent(fn (Request $request): bool => $request->url() === "https://elasticsearch.test/brain_memories/_doc/{$memory->id}"
|
|
&& $request->method() === 'PUT'
|
|
&& $request['id'] === $memory->id
|
|
&& $request['content'] === 'Elasticsearch mirrors brain memories for lexical search.'
|
|
&& $request['type'] === 'architecture'
|
|
&& $request['tags'] === ['brain', 'search']
|
|
&& $request['project'] === 'agent'
|
|
&& $request['workspace_id'] === 42
|
|
&& $request['org'] === 'core'
|
|
&& $request['confidence'] === 0.95
|
|
&& $request['indexed_at'] === '2026-04-23T12:00:00+00:00');
|
|
});
|
|
|
|
test('BrainService_elasticIndex_Bad_throws_when_indexing_fails', function (): void {
|
|
$memory = elasticBrainMemory();
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memory->id}" => Http::response(['error' => 'unavailable'], 500),
|
|
]);
|
|
|
|
expect(fn () => elasticBrainService()->elasticIndex($memory))
|
|
->toThrow(RuntimeException::class, 'Elasticsearch index failed: 500');
|
|
});
|
|
|
|
test('BrainService_elasticIndex_Ugly_indexes_null_optional_fields', function (): void {
|
|
$memory = elasticBrainMemory([
|
|
'tags' => null,
|
|
'project' => null,
|
|
'indexed_at' => null,
|
|
]);
|
|
$memory->setAttribute('org', null);
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memory->id}" => Http::response(['result' => 'created']),
|
|
]);
|
|
|
|
elasticBrainService()->elasticIndex($memory);
|
|
|
|
Http::assertSent(fn (Request $request): bool => $request->url() === "https://elasticsearch.test/brain_memories/_doc/{$memory->id}"
|
|
&& $request->method() === 'PUT'
|
|
&& $request['tags'] === []
|
|
&& $request['project'] === null
|
|
&& $request['org'] === null
|
|
&& $request['indexed_at'] === null);
|
|
});
|
|
|
|
test('BrainService_elasticDelete_Good_deletes_memory_document', function (): void {
|
|
$memoryId = Str::uuid()->toString();
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memoryId}" => Http::response(['result' => 'deleted']),
|
|
]);
|
|
|
|
elasticBrainService()->elasticDelete($memoryId);
|
|
|
|
Http::assertSent(fn (Request $request): bool => $request->url() === "https://elasticsearch.test/brain_memories/_doc/{$memoryId}"
|
|
&& $request->method() === 'DELETE');
|
|
});
|
|
|
|
test('BrainService_elasticDelete_Bad_throws_when_delete_fails', function (): void {
|
|
$memoryId = Str::uuid()->toString();
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memoryId}" => Http::response(['error' => 'unavailable'], 500),
|
|
]);
|
|
|
|
expect(fn () => elasticBrainService()->elasticDelete($memoryId))
|
|
->toThrow(RuntimeException::class, 'Elasticsearch delete failed: 500');
|
|
});
|
|
|
|
test('BrainService_elasticDelete_Ugly_throws_when_document_is_missing', function (): void {
|
|
$memoryId = Str::uuid()->toString();
|
|
|
|
Http::fake([
|
|
"https://elasticsearch.test/brain_memories/_doc/{$memoryId}" => Http::response(['result' => 'not_found'], 404),
|
|
]);
|
|
|
|
expect(fn () => elasticBrainService()->elasticDelete($memoryId))
|
|
->toThrow(RuntimeException::class, 'Elasticsearch delete failed: 404');
|
|
});
|
|
|
|
test('BrainService_elasticSearch_Good_posts_multi_match_query_with_filters', function (): void {
|
|
Http::fake([
|
|
'https://elasticsearch.test/brain_memories/_search' => Http::response([
|
|
'hits' => [
|
|
'hits' => [
|
|
['_id' => 'memory-1', '_score' => 1.25],
|
|
],
|
|
],
|
|
]),
|
|
]);
|
|
|
|
$result = elasticBrainService()->elasticSearch('queue indexing', [
|
|
'workspace_id' => 42,
|
|
'org' => 'core',
|
|
'project' => 'agent',
|
|
'type' => ['architecture', 'pattern'],
|
|
'tags' => 'brain',
|
|
'min_confidence' => 0.7,
|
|
]);
|
|
|
|
expect($result['hits']['hits'][0]['_id'])->toBe('memory-1');
|
|
|
|
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']['must'][0]['multi_match']['fields'] === ['content^3', 'type', 'tags', 'project', 'org']
|
|
&& $request['query']['bool']['filter'] === [
|
|
['term' => ['workspace_id' => 42]],
|
|
['term' => ['org' => 'core']],
|
|
['term' => ['project' => 'agent']],
|
|
['terms' => ['type' => ['architecture', 'pattern']]],
|
|
['term' => ['tags' => 'brain']],
|
|
['range' => ['confidence' => ['gte' => 0.7]]],
|
|
]);
|
|
});
|
|
|
|
test('BrainService_elasticSearch_Bad_throws_when_search_fails', function (): void {
|
|
Http::fake([
|
|
'https://elasticsearch.test/brain_memories/_search' => Http::response(['error' => 'unavailable'], 503),
|
|
]);
|
|
|
|
expect(fn () => elasticBrainService()->elasticSearch('queue indexing'))
|
|
->toThrow(RuntimeException::class, 'Elasticsearch search failed: 503');
|
|
});
|
|
|
|
test('BrainService_elasticSearch_Ugly_uses_match_all_for_empty_query', function (): void {
|
|
Http::fake([
|
|
'https://elasticsearch.test/brain_memories/_search' => Http::response(['hits' => ['hits' => []]]),
|
|
]);
|
|
|
|
elasticBrainService()->elasticSearch('', [
|
|
'tags' => ['brain', 'search'],
|
|
]);
|
|
|
|
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
|
|
&& $request->method() === 'POST'
|
|
&& isset($request['query']['bool']['must'][0]['match_all'])
|
|
&& $request['query']['bool']['filter'] === [
|
|
['terms' => ['tags' => ['brain', 'search']]],
|
|
]);
|
|
});
|