- BrainService: Ollama embeddings + Qdrant vector upsert/search/delete - brain_remember: store knowledge with type, tags, confidence, supersession - brain_recall: semantic search with filter by project/type/agent/confidence - brain_forget: workspace-scoped deletion from both stores - brain_list: MariaDB query with model scopes, no vector search - Config: brain.ollama_url, brain.qdrant_url, brain.collection - Boot: BrainService singleton + tool registration via AgentToolRegistry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
236 lines
6.9 KiB
PHP
236 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Agentic\Services;
|
|
|
|
use Core\Mod\Agentic\Models\BrainMemory;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class BrainService
|
|
{
|
|
private const EMBEDDING_MODEL = 'nomic-embed-text';
|
|
|
|
private const VECTOR_DIMENSION = 768;
|
|
|
|
public function __construct(
|
|
private string $ollamaUrl = 'http://localhost:11434',
|
|
private string $qdrantUrl = 'http://localhost:6334',
|
|
private string $collection = 'openbrain',
|
|
) {}
|
|
|
|
/**
|
|
* Generate an embedding vector for the given text.
|
|
*
|
|
* @return array<float>
|
|
*
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function embed(string $text): array
|
|
{
|
|
$response = Http::timeout(30)
|
|
->post("{$this->ollamaUrl}/api/embeddings", [
|
|
'model' => self::EMBEDDING_MODEL,
|
|
'prompt' => $text,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
throw new \RuntimeException("Ollama embedding failed: {$response->status()}");
|
|
}
|
|
|
|
return $response->json('embedding');
|
|
}
|
|
|
|
/**
|
|
* Store a memory in both MariaDB and Qdrant.
|
|
*
|
|
* If the memory supersedes an older one, the old entry is
|
|
* removed from both stores.
|
|
*/
|
|
public function remember(BrainMemory $memory): void
|
|
{
|
|
$vector = $this->embed($memory->content);
|
|
|
|
$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,
|
|
'created_at' => $memory->created_at->toIso8601String(),
|
|
]);
|
|
$payload['vector'] = $vector;
|
|
|
|
$this->qdrantUpsert([$payload]);
|
|
|
|
if ($memory->supersedes_id) {
|
|
$this->qdrantDelete([$memory->supersedes_id]);
|
|
BrainMemory::where('id', $memory->supersedes_id)->delete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Semantic search: find memories similar to the query.
|
|
*
|
|
* @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
|
|
{
|
|
$vector = $this->embed($query);
|
|
|
|
$filter['workspace_id'] = $workspaceId;
|
|
$qdrantFilter = $this->buildQdrantFilter($filter);
|
|
|
|
$response = Http::timeout(10)
|
|
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [
|
|
'vector' => $vector,
|
|
'filter' => $qdrantFilter,
|
|
'limit' => $topK,
|
|
'with_payload' => false,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
throw new \RuntimeException("Qdrant search failed: {$response->status()}");
|
|
}
|
|
|
|
$results = $response->json('result', []);
|
|
$ids = array_column($results, 'id');
|
|
$scoreMap = [];
|
|
|
|
foreach ($results as $r) {
|
|
$scoreMap[$r['id']] = $r['score'];
|
|
}
|
|
|
|
if (empty($ids)) {
|
|
return ['memories' => [], 'scores' => []];
|
|
}
|
|
|
|
$memories = BrainMemory::whereIn('id', $ids)
|
|
->active()
|
|
->latestVersions()
|
|
->get()
|
|
->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids))
|
|
->values();
|
|
|
|
return [
|
|
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
|
|
'scores' => $scoreMap,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Remove a memory from both Qdrant and MariaDB.
|
|
*/
|
|
public function forget(string $id): void
|
|
{
|
|
$this->qdrantDelete([$id]);
|
|
BrainMemory::where('id', $id)->delete();
|
|
}
|
|
|
|
/**
|
|
* Ensure the Qdrant collection exists, creating it if needed.
|
|
*/
|
|
public function ensureCollection(): void
|
|
{
|
|
$response = Http::timeout(5)
|
|
->get("{$this->qdrantUrl}/collections/{$this->collection}");
|
|
|
|
if ($response->status() === 404) {
|
|
Http::timeout(10)
|
|
->put("{$this->qdrantUrl}/collections/{$this->collection}", [
|
|
'vectors' => [
|
|
'size' => self::VECTOR_DIMENSION,
|
|
'distance' => 'Cosine',
|
|
],
|
|
]);
|
|
Log::info("OpenBrain: created Qdrant collection '{$this->collection}'");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a Qdrant point payload.
|
|
*
|
|
* @param array<string, mixed> $metadata
|
|
* @return array{id: string, payload: array<string, mixed>}
|
|
*/
|
|
public function buildQdrantPayload(string $id, array $metadata): array
|
|
{
|
|
return [
|
|
'id' => $id,
|
|
'payload' => $metadata,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build a Qdrant filter from criteria.
|
|
*
|
|
* @param array<string, mixed> $criteria
|
|
* @return array{must: array}
|
|
*/
|
|
public function buildQdrantFilter(array $criteria): array
|
|
{
|
|
$must = [];
|
|
|
|
if (isset($criteria['workspace_id'])) {
|
|
$must[] = ['key' => 'workspace_id', 'match' => ['value' => $criteria['workspace_id']]];
|
|
}
|
|
|
|
if (isset($criteria['project'])) {
|
|
$must[] = ['key' => 'project', 'match' => ['value' => $criteria['project']]];
|
|
}
|
|
|
|
if (isset($criteria['type'])) {
|
|
if (is_array($criteria['type'])) {
|
|
$must[] = ['key' => 'type', 'match' => ['any' => $criteria['type']]];
|
|
} else {
|
|
$must[] = ['key' => 'type', 'match' => ['value' => $criteria['type']]];
|
|
}
|
|
}
|
|
|
|
if (isset($criteria['agent_id'])) {
|
|
$must[] = ['key' => 'agent_id', 'match' => ['value' => $criteria['agent_id']]];
|
|
}
|
|
|
|
if (isset($criteria['min_confidence'])) {
|
|
$must[] = ['key' => 'confidence', 'range' => ['gte' => $criteria['min_confidence']]];
|
|
}
|
|
|
|
return ['must' => $must];
|
|
}
|
|
|
|
/**
|
|
* Upsert points into Qdrant.
|
|
*
|
|
* @param array<array> $points
|
|
*
|
|
* @throws \RuntimeException
|
|
*/
|
|
private function qdrantUpsert(array $points): void
|
|
{
|
|
$response = Http::timeout(10)
|
|
->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [
|
|
'points' => $points,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]);
|
|
throw new \RuntimeException("Qdrant upsert failed: {$response->status()}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete points from Qdrant by ID.
|
|
*
|
|
* @param array<string> $ids
|
|
*/
|
|
private function qdrantDelete(array $ids): void
|
|
{
|
|
Http::timeout(10)
|
|
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [
|
|
'points' => $ids,
|
|
]);
|
|
}
|
|
}
|