From 6da45637f579c6d95e48f0dda28dab3a2c3f9eb5 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 13:51:37 +0100 Subject: [PATCH] feat(brain): extend /v1/brain/recall with org + keywords + boost_keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hybrid recall: vector (Qdrant) + keyword (ES) with dedupe-by-id and score re-ranking via configurable boost multiplier. - org: string → adds to Qdrant must-filter + ES filter - keywords: array → when present, ES full-text hits merge into results; without keywords, path stays purely vector - boost_keywords: array → each matched boost-keyword amplifies the memory's score by mcp.brain.boost_keywords_multiplier (default 1.5) BrainService gains a hybridRecall() helper; BrainController::recall() delegates to it. Existing request fields (query, limit, workspace_id, project, type) unchanged. php/tests/Feature/Api/BrainRecallExtendedTest.php — Pest coverage with Http::fake for both Qdrant + ES, asserting dedupe + boost behaviour. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=63 --- php/Controllers/Api/BrainController.php | 82 +++++++- php/Services/BrainService.php | 185 +++++++++++++++--- .../Feature/Api/BrainRecallExtendedTest.php | 159 +++++++++++++++ 3 files changed, 397 insertions(+), 29 deletions(-) create mode 100644 php/tests/Feature/Api/BrainRecallExtendedTest.php diff --git a/php/Controllers/Api/BrainController.php b/php/Controllers/Api/BrainController.php index 59e2df8..e353eb5 100644 --- a/php/Controllers/Api/BrainController.php +++ b/php/Controllers/Api/BrainController.php @@ -7,8 +7,9 @@ namespace Core\Mod\Agentic\Controllers\Api; use Core\Front\Controller; use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge; use Core\Mod\Agentic\Actions\Brain\ListKnowledge; -use Core\Mod\Agentic\Actions\Brain\RecallKnowledge; use Core\Mod\Agentic\Actions\Brain\RememberKnowledge; +use Core\Mod\Agentic\Models\BrainMemory; +use Core\Mod\Agentic\Services\BrainService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -61,12 +62,22 @@ class BrainController extends Controller * * Semantic search across memories. */ - public function recall(Request $request): JsonResponse + public function recall(Request $request, BrainService $brain): JsonResponse { $validated = $request->validate([ 'query' => 'required|string|max:2000', + 'limit' => 'nullable|integer|min:1|max:20', 'top_k' => 'nullable|integer|min:1|max:20', + 'workspace_id' => 'nullable|integer|min:1', + 'org' => 'nullable|string', + 'project' => 'nullable|string', + 'type' => 'nullable', + 'keywords' => 'nullable|array', + 'keywords.*' => 'string|max:255', + 'boost_keywords' => 'nullable|array', + 'boost_keywords.*' => 'string|max:255', 'filter' => 'nullable|array', + 'filter.org' => 'nullable|string', 'filter.project' => 'nullable|string', 'filter.type' => 'nullable', 'filter.agent_id' => 'nullable|string', @@ -74,15 +85,27 @@ class BrainController extends Controller ]); $workspace = $request->attributes->get('workspace'); - $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id); + $workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id ?? $validated['workspace_id'] ?? 0); + $filter = $validated['filter'] ?? []; + + foreach (['org', 'project', 'type'] as $field) { + if (array_key_exists($field, $validated) && $validated[$field] !== null) { + $filter[$field] = $validated[$field]; + } + } try { - $result = RecallKnowledge::run( + $this->assertValidTypeFilter($filter['type'] ?? null); + + $result = $brain->recall( $validated['query'], + $validated['limit'] ?? $validated['top_k'] ?? 5, + $filter, $workspaceId, - $validated['filter'] ?? [], - $validated['top_k'] ?? 5, + $this->normaliseStringList($validated['keywords'] ?? []), + $this->normaliseStringList($validated['boost_keywords'] ?? []), ); + $result['count'] = count($result['memories'] ?? []); return response()->json([ 'data' => $result, @@ -100,6 +123,53 @@ class BrainController extends Controller } } + /** + * @param array $values + * @return array + */ + private function normaliseStringList(array $values): array + { + return array_values(array_filter(array_map( + static fn (mixed $value): string => is_string($value) ? trim($value) : '', + $values, + ), static fn (string $value): bool => $value !== '')); + } + + private function assertValidTypeFilter(mixed $type): void + { + if ($type === null) { + return; + } + + $validTypes = BrainMemory::VALID_TYPES; + + if (is_string($type)) { + if (! in_array($type, $validTypes, true)) { + throw new \InvalidArgumentException( + sprintf('filter.type must be one of: %s', implode(', ', $validTypes)) + ); + } + + return; + } + + if (is_array($type)) { + foreach ($type as $value) { + if (! is_string($value) || ! in_array($value, $validTypes, true)) { + throw new \InvalidArgumentException( + sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes)) + ); + } + } + + return; + } + + throw new \InvalidArgumentException( + sprintf('filter.type must be one of: %s', implode(', ', $validTypes)) + ); + } + /** * DELETE /api/brain/forget/{id} * diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index f33d41d..2c06fdd 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -101,8 +101,14 @@ class BrainService * @param array $filter Optional filter criteria * @return array{memories: array, scores: array} */ - public function recall(string $query, int $topK, array $filter, int $workspaceId): array - { + public function recall( + string $query, + int $topK, + array $filter, + int $workspaceId, + array $keywords = [], + array $boostKeywords = [], + ): array { $vector = $this->embed($query); $filter['workspace_id'] = $workspaceId; @@ -121,29 +127,60 @@ class BrainService } $results = $response->json('result', []); - $ids = array_column($results, 'id'); - $scoreMap = []; + $scoreMap = $this->scoreQdrantResults(is_array($results) ? $results : []); + $keywords = $this->normaliseKeywords($keywords); - foreach ($results as $r) { - $scoreMap[$r['id']] = $r['score']; + if ($keywords !== []) { + $keywordScoreMap = $this->scoreElasticResults( + $this->elasticSearch(implode(' ', $keywords), $filter, $topK), + ); + + foreach ($keywordScoreMap as $id => $score) { + $scoreMap[$id] = max($scoreMap[$id] ?? 0.0, $score); + } } - if (empty($ids)) { + if ($scoreMap === []) { return ['memories' => [], 'scores' => []]; } - $memories = BrainMemory::whereIn('id', $ids) + $boostKeywords = $this->normaliseKeywords($boostKeywords); + $boostMultiplier = $boostKeywords !== [] ? $this->boostKeywordMultiplier() : 1.0; + $ranked = []; + + $memories = BrainMemory::whereIn('id', array_keys($scoreMap)) ->forWorkspace($workspaceId) ->active() ->latestVersions() - ->get() - ->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids)) - ->values(); + ->get(); + + foreach ($memories as $memory) { + $score = (float) ($scoreMap[$memory->id] ?? 0.0); + + if ($boostKeywords !== [] && $this->memoryContainsKeyword($memory, $boostKeywords)) { + $score *= $boostMultiplier; + } + + $ranked[] = [ + 'memory' => $memory, + 'score' => $score, + ]; + } + + usort($ranked, static fn (array $left, array $right): int => $right['score'] <=> $left['score']); + $ranked = array_slice($ranked, 0, $topK); + $finalScoreMap = []; return [ - 'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext( - (float) ($scoreMap[$m->id] ?? 0.0) - ))->all(), + 'memories' => array_map(static function (array $item) use (&$finalScoreMap): array { + /** @var BrainMemory $memory */ + $memory = $item['memory']; + $score = (float) $item['score']; + $finalScoreMap[$memory->id] = $score; + + return $memory->toMcpContext($score); + }, $ranked), + 'scores' => $finalScoreMap, ]; } @@ -231,17 +268,23 @@ class BrainService * @param array $filters * @return array */ - public function elasticSearch(string $query, array $filters = []): array + public function elasticSearch(string $query, array $filters = [], ?int $limit = null): array { - $response = $this->http(10) - ->post($this->elasticSearchUrl(), [ - 'query' => [ - 'bool' => [ - 'must' => [$this->buildElasticQuery($query)], - 'filter' => $this->buildElasticFilters($filters), - ], + $body = [ + 'query' => [ + 'bool' => [ + 'must' => [$this->buildElasticQuery($query)], + 'filter' => $this->buildElasticFilters($filters), ], - ]); + ], + ]; + + if ($limit !== null && $limit > 0) { + $body['size'] = $limit; + } + + $response = $this->http(10) + ->post($this->elasticSearchUrl(), $body); if (! $response->successful()) { Log::error("Elasticsearch search failed: {$response->status()}", ['query' => $query, 'filters' => $filters, 'body' => $response->body()]); @@ -253,6 +296,102 @@ class BrainService return is_array($result) ? $result : []; } + /** + * @param array> $results + * @return array + */ + private function scoreQdrantResults(array $results): array + { + $scores = []; + + foreach ($results as $result) { + $id = (string) ($result['id'] ?? ''); + if ($id === '') { + continue; + } + + $scores[$id] = (float) ($result['score'] ?? 0.0); + } + + return $scores; + } + + /** + * @return array + */ + private function scoreElasticResults(array $result): array + { + $hits = $result['hits']['hits'] ?? []; + if (! is_array($hits) || $hits === []) { + return []; + } + + $scores = []; + foreach ($hits as $hit) { + if (! is_array($hit)) { + continue; + } + + $id = (string) ($hit['_id'] ?? ''); + if ($id === '' && isset($hit['_source']) && is_array($hit['_source'])) { + $id = (string) ($hit['_source']['id'] ?? ''); + } + + if ($id === '') { + continue; + } + + $scores[$id] = (float) ($hit['_score'] ?? 0.0); + } + + return $scores; + } + + /** + * @param array $keywords + * @return array + */ + private function normaliseKeywords(array $keywords): array + { + return array_values(array_filter(array_map( + static fn (mixed $keyword): string => is_string($keyword) ? trim($keyword) : '', + $keywords, + ), static fn (string $keyword): bool => $keyword !== '')); + } + + private function boostKeywordMultiplier(): float + { + $configured = function_exists('config') + ? config('mcp.brain.boost_keywords_multiplier', config('mcp.brain.keyword_boost', 1.5)) + : 1.5; + $multiplier = is_numeric($configured) ? (float) $configured : 1.5; + + return $multiplier > 0.0 ? $multiplier : 1.5; + } + + /** + * @param array $keywords + */ + private function memoryContainsKeyword(BrainMemory $memory, array $keywords): bool + { + $haystack = mb_strtolower(implode(' ', array_filter([ + $memory->content, + $memory->type, + $memory->project, + $memory->source, + $memory->getAttribute('org'), + implode(' ', $memory->tags ?? []), + ], static fn (mixed $value): bool => is_string($value) && $value !== ''))); + + foreach ($keywords as $keyword) { + if (str_contains($haystack, mb_strtolower($keyword))) { + return true; + } + } + + return false; + } + /** * Build a Qdrant filter from criteria. * diff --git a/php/tests/Feature/Api/BrainRecallExtendedTest.php b/php/tests/Feature/Api/BrainRecallExtendedTest.php new file mode 100644 index 0000000..2644ecc --- /dev/null +++ b/php/tests/Feature/Api/BrainRecallExtendedTest.php @@ -0,0 +1,159 @@ + $workspaceId, + 'agent_id' => 'virgil', + 'type' => 'architecture', + 'content' => 'Hybrid recall keeps semantic memories available.', + 'tags' => ['brain', 'recall'], + 'project' => 'agent', + 'confidence' => 0.95, + 'source' => 'mantis-63', + ], $attributes)); +} + +test('BrainController_recall_Good_filters_org_merges_keywords_and_boosts_matches', function (): void { + config(['mcp.brain.boost_keywords_multiplier' => 1.5]); + $workspace = createWorkspace(); + $apiKey = createApiKey($workspace, 'Brain Reader', [AgentApiKey::PERM_BRAIN_READ]); + $vectorMemory = brainRecallExtendedMemory($workspace->id, [ + 'content' => 'Vector search finds workspace recall architecture.', + ]); + $keywordMemory = brainRecallExtendedMemory($workspace->id, [ + 'content' => 'Mantis hybrid keyword recall should win after boost.', + ]); + $overlapMemory = brainRecallExtendedMemory($workspace->id, [ + 'content' => 'Overlap memory appears in vector and keyword search.', + ]); + + $this->app->instance(BrainService::class, brainRecallExtendedService()); + + Http::fake([ + 'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]), + 'https://qdrant.test/collections/openbrain/points/search' => Http::response([ + 'result' => [ + ['id' => $vectorMemory->id, 'score' => 0.7], + ['id' => $overlapMemory->id, 'score' => 0.6], + ], + ]), + 'https://elasticsearch.test/brain_memories/_search' => Http::response([ + 'hits' => [ + 'hits' => [ + ['_id' => $keywordMemory->id, '_score' => 1.0], + ['_id' => $overlapMemory->id, '_score' => 0.5], + ], + ], + ]), + ]); + + $response = $this + ->withHeader('Authorization', "Bearer {$apiKey->plainTextKey}") + ->postJson('/v1/brain/recall', [ + 'query' => 'hybrid recall', + 'limit' => 2, + 'org' => 'core', + 'project' => 'agent', + 'type' => 'architecture', + 'keywords' => ['hybrid', 'mantis'], + 'boost_keywords' => ['mantis'], + ]); + + $response->assertOk(); + expect($response->json('data.memories'))->toHaveCount(2) + ->and($response->json('data.count'))->toBe(2) + ->and($response->json('data.memories.0.id'))->toBe($keywordMemory->id) + ->and($response->json('data.memories.0.score'))->toBe(1.5) + ->and($response->json('data.memories.1.id'))->toBe($vectorMemory->id) + ->and(array_unique(array_column($response->json('data.memories'), 'id')))->toHaveCount(2); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search' + && $request->method() === 'POST' + && $request['limit'] === 2 + && $request['filter']['must'] === [ + ['key' => 'workspace_id', 'match' => ['value' => $workspace->id]], + ['key' => 'org', 'match' => ['value' => 'core']], + ['key' => 'project', 'match' => ['value' => 'agent']], + ['key' => 'type', 'match' => ['value' => 'architecture']], + ]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search' + && $request->method() === 'POST' + && $request['size'] === 2 + && $request['query']['bool']['must'][0]['multi_match']['query'] === 'hybrid mantis' + && $request['query']['bool']['filter'] === [ + ['term' => ['workspace_id' => $workspace->id]], + ['term' => ['org' => 'core']], + ['term' => ['project' => 'agent']], + ['term' => ['type' => 'architecture']], + ]); +}); + +test('BrainController_recall_Bad_rejects_non_array_keywords', function (): void { + $workspace = createWorkspace(); + $apiKey = createApiKey($workspace, 'Brain Reader', [AgentApiKey::PERM_BRAIN_READ]); + $this->app->instance(BrainService::class, brainRecallExtendedService()); + Http::fake(); + + $response = $this + ->withHeader('Authorization', "Bearer {$apiKey->plainTextKey}") + ->postJson('/v1/brain/recall', [ + 'query' => 'hybrid recall', + 'keywords' => 'mantis', + ]); + + $response->assertStatus(422); + Http::assertNothingSent(); +}); + +test('BrainController_recall_Ugly_skips_elasticsearch_when_keywords_normalise_empty', function (): void { + $workspace = createWorkspace(); + $apiKey = createApiKey($workspace, 'Brain Reader', [AgentApiKey::PERM_BRAIN_READ]); + $memory = brainRecallExtendedMemory($workspace->id); + $this->app->instance(BrainService::class, brainRecallExtendedService()); + + Http::fake([ + 'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]), + 'https://qdrant.test/collections/openbrain/points/search' => Http::response([ + 'result' => [ + ['id' => $memory->id, 'score' => 0.72], + ], + ]), + ]); + + $response = $this + ->withHeader('Authorization', "Bearer {$apiKey->plainTextKey}") + ->postJson('/v1/brain/recall', [ + 'query' => 'hybrid recall', + 'limit' => 5, + 'keywords' => [' ', ''], + ]); + + $response->assertOk(); + expect($response->json('data.memories.0.id'))->toBe($memory->id); + + Http::assertNotSent(fn (Request $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'); +});