feat(brain): make BrainService::remember() async via EmbedMemory job

remember() now writes the brain_memories row with indexed_at=null and
dispatches EmbedMemory::dispatch($memory->id) for async Qdrant + ES
indexing, instead of calling qdrantUpsert() synchronously. Response shape
matches the row state — caller gets the memory immediately, the Job
flips indexed_at once the Qdrant write succeeds.

Superseded rows still soft-delete synchronously (part of the remember
contract, not the indexing path).

php/tests/Feature/Services/BrainServiceRememberTest.php uses Queue::fake()
to assert EmbedMemory is dispatched and BrainService::qdrantUpsert() is
NOT called directly (subclass probe).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=55
This commit is contained in:
Snider 2026-04-23 13:29:30 +01:00
parent 47f241d880
commit 677d890308
2 changed files with 105 additions and 22 deletions

View file

@ -68,43 +68,31 @@ class BrainService
}
/**
* Store a memory in both MariaDB and Qdrant.
* Store a memory and queue asynchronous indexing.
*
* Creates the MariaDB record and upserts the Qdrant vector within
* a single DB transaction. If the memory supersedes an older one,
* the old entry is soft-deleted from MariaDB and removed from Qdrant.
* Creates the brain database record within a DB transaction and dispatches
* EmbedMemory after commit so embedding, Qdrant, and Elasticsearch
* indexing happen on the queue.
*
* @param array<string, mixed> $attributes Fillable attributes for BrainMemory
* @return BrainMemory The created memory
*/
public function remember(array $attributes): BrainMemory
{
$vector = $this->embed($attributes['content']);
$attributes['indexed_at'] = null;
return DB::connection('brain')->transaction(function () use ($attributes, $vector) {
$memory = DB::connection('brain')->transaction(function () use ($attributes) {
$memory = BrainMemory::create($attributes);
$payload = $this->buildQdrantPayload($memory->id, [
'workspace_id' => $memory->workspace_id,
'agent_id' => $memory->agent_id,
'type' => $memory->type,
'tags' => $memory->tags ?? [],
'project' => $memory->project,
'confidence' => $memory->confidence,
'source' => $memory->source ?? 'manual',
'created_at' => $memory->created_at->toIso8601String(),
]);
$payload['vector'] = $vector;
$this->qdrantUpsert([$payload]);
if ($memory->supersedes_id) {
BrainMemory::where('id', $memory->supersedes_id)->delete();
$this->qdrantDelete([$memory->supersedes_id]);
}
return $memory;
});
\Core\Mod\Agentic\Jobs\EmbedMemory::dispatch($memory->id);
return $memory;
}
/**

View file

@ -0,0 +1,95 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Jobs\EmbedMemory;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Queue;
function rememberBrainService(): BrainService
{
return new class extends BrainService
{
public bool $qdrantUpsertCalled = false;
/**
* @return array<float>
*/
public function embed(string $text): array
{
return array_fill(0, 768, 0.125);
}
public function qdrantUpsert(array $points): void
{
$this->qdrantUpsertCalled = true;
}
};
}
function rememberBrainAttributes(array $attributes = []): array
{
return array_merge([
'workspace_id' => $attributes['workspace_id'] ?? createWorkspace()->id,
'agent_id' => 'virgil',
'type' => 'architecture',
'content' => 'Brain memories are indexed by queued jobs.',
'tags' => ['brain', 'queue'],
'project' => 'agent',
'confidence' => 0.95,
'source' => 'ticket-55',
], $attributes);
}
test('BrainService_remember_Good_returns_unindexed_memory_and_dispatches_embed_job', function (): void {
Queue::fake();
$brain = rememberBrainService();
$memory = $brain->remember(rememberBrainAttributes([
'indexed_at' => now(),
]));
expect($memory->exists)->toBeTrue()
->and($memory->indexed_at)->toBeNull()
->and($memory->fresh()->indexed_at)->toBeNull()
->and($brain->qdrantUpsertCalled)->toBeFalse();
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
});
test('BrainService_remember_Bad_does_not_call_qdrant_upsert_directly', function (): void {
Queue::fake();
$brain = rememberBrainService();
$brain->remember(rememberBrainAttributes());
expect($brain->qdrantUpsertCalled)->toBeFalse();
Queue::assertPushed(EmbedMemory::class);
});
test('BrainService_remember_Ugly_soft_deletes_superseded_memory_before_dispatching_job', function (): void {
Queue::fake();
$brain = rememberBrainService();
$workspace = createWorkspace();
$oldMemory = BrainMemory::create(rememberBrainAttributes([
'workspace_id' => $workspace->id,
'content' => 'Old memory version.',
]));
$memory = $brain->remember(rememberBrainAttributes([
'workspace_id' => $workspace->id,
'content' => 'New memory version.',
'supersedes_id' => $oldMemory->id,
]));
expect(BrainMemory::find($oldMemory->id))->toBeNull()
->and(BrainMemory::withTrashed()->find($oldMemory->id)?->trashed())->toBeTrue()
->and($memory->indexed_at)->toBeNull()
->and($brain->qdrantUpsertCalled)->toBeFalse();
Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $memory->id);
});