verifySsl ? Http::timeout($timeout) : Http::withoutVerifying()->timeout($timeout); } /** * Generate an embedding vector for the given text. * * @return array * * @throws \RuntimeException */ public function embed(string $text): array { $response = $this->http(30) ->post("{$this->ollamaUrl}/api/embeddings", [ 'model' => $this->embeddingModel, 'prompt' => $text, ]); if (! $response->successful()) { throw new \RuntimeException("Ollama embedding failed: {$response->status()}"); } $embedding = $response->json('embedding'); if (! is_array($embedding) || empty($embedding)) { throw new \RuntimeException('Ollama returned no embedding vector'); } return $embedding; } /** * Store a memory in both MariaDB and Qdrant. * * 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. * * @param array $attributes Fillable attributes for BrainMemory * @return BrainMemory The created memory */ public function remember(array $attributes): BrainMemory { $vector = $this->embed($attributes['content']); return DB::connection('brain')->transaction(function () use ($attributes, $vector) { $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, '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; }); } /** * Semantic search: find memories similar to the query. * * @param array $filter Optional filter criteria * @return array{memories: array, scores: array} */ 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 = $this->http(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) ->forWorkspace($workspaceId) ->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 { DB::connection('brain')->transaction(function () use ($id) { BrainMemory::where('id', $id)->delete(); $this->qdrantDelete([$id]); }); } /** * Ensure the Qdrant collection exists, creating it if needed. */ public function ensureCollection(): void { $response = $this->http(5) ->get("{$this->qdrantUrl}/collections/{$this->collection}"); if ($response->status() === 404) { $createResponse = $this->http(10) ->put("{$this->qdrantUrl}/collections/{$this->collection}", [ 'vectors' => [ 'size' => self::VECTOR_DIMENSION, 'distance' => 'Cosine', ], ]); if (! $createResponse->successful()) { throw new \RuntimeException("Qdrant collection creation failed: {$createResponse->status()}"); } Log::info("OpenBrain: created Qdrant collection '{$this->collection}'"); } } /** * Build a Qdrant point payload. * * @param array $metadata * @return array{id: string, payload: array} */ public function buildQdrantPayload(string $id, array $metadata): array { return [ 'id' => $id, 'payload' => $metadata, ]; } /** * Build a Qdrant filter from criteria. * * @param array $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 $points * * @throws \RuntimeException */ private function qdrantUpsert(array $points): void { $response = $this->http(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 $ids */ private function qdrantDelete(array $ids): void { $response = $this->http(10) ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [ 'points' => $ids, ]); if (! $response->successful()) { Log::warning("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]); } } }