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:
parent
34525038a8
commit
6da45637f5
3 changed files with 397 additions and 29 deletions
|
|
@ -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}
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
159
php/tests/Feature/Api/BrainRecallExtendedTest.php
Normal file
159
php/tests/Feature/Api/BrainRecallExtendedTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue