feat(brain): extend /v1/brain/recall with org + keywords + boost_keywords

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<string> → when present, ES full-text hits merge into
  results; without keywords, path stays purely vector
- boost_keywords: array<string> → 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 <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=63
This commit is contained in:
Snider 2026-04-23 13:51:37 +01:00
parent 34525038a8
commit 6da45637f5
3 changed files with 397 additions and 29 deletions

View file

@ -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<int, mixed> $values
* @return array<int, string>
*/
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}
*

View file

@ -101,8 +101,14 @@ class BrainService
* @param array<string, mixed> $filter Optional filter criteria
* @return array{memories: array, scores: array<string, float>}
*/
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<string, mixed> $filters
* @return array<string, mixed>
*/
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<int, array<string, mixed>> $results
* @return array<string, float>
*/
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<string, float>
*/
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<int, mixed> $keywords
* @return array<int, string>
*/
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<int, string> $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.
*

View file

@ -0,0 +1,159 @@
<?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 Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
function brainRecallExtendedService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
elasticsearchUrl: 'https://elasticsearch.test',
);
}
function brainRecallExtendedMemory(int $workspaceId, array $attributes = []): BrainMemory
{
return BrainMemory::create(array_merge([
'workspace_id' => $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');
});