qdrantUrl = rtrim($url, '/'); $this->collection = $collection; } public function ensureCollection(int $dims = 1024): void { $info = $this->request('GET', "/collections/{$this->collection}"); if ($info['status'] === 200) return; $this->request('PUT', "/collections/{$this->collection}", [ 'vectors' => ['size' => $dims, 'distance' => 'Cosine'], ]); } public function upsertBatch(array $points): void { if (empty($points)) return; $resp = $this->request('PUT', "/collections/{$this->collection}/points?wait=true", [ 'points' => $points, ]); if ($resp['status'] !== 200) { throw new RuntimeException('Qdrant upsert failed (HTTP ' . $resp['status'] . '): ' . json_encode($resp['body'])); } } public function deleteByFilter(array $filter): void { $this->request('POST', "/collections/{$this->collection}/points/delete", [ 'filter' => $filter, ]); } public function setPayloadByFilter(array $payload, array $filter): void { $this->request('POST', "/collections/{$this->collection}/points/payload", [ 'payload' => $payload, 'filter' => $filter, ]); } public function search(array $vector, array $filter = [], int $limit = 8, float $minScore = 0.28): array { $body = [ 'vector' => $vector, 'limit' => $limit, 'with_payload' => true, 'score_threshold'=> $minScore, ]; if (!empty($filter)) { $body['filter'] = $filter; } $resp = $this->request('POST', "/collections/{$this->collection}/points/search", $body); if ($resp['status'] !== 200) { return []; } return $resp['body']['result'] ?? []; } /** * Filtro a 3 livelli (SYSTEM/FIRM/ORG) basato sull'utente. * Restituisce SOLO chunks visibili a quell'utente. * * @param array $userContext ['user_id'=>int, 'organization_id'=>int|null, 'consulting_firm_id'=>int|null] */ public static function buildAuthzFilter(array $userContext): array { $firmId = isset($userContext['consulting_firm_id']) && $userContext['consulting_firm_id'] !== null ? (int)$userContext['consulting_firm_id'] : null; $orgId = isset($userContext['organization_id']) && $userContext['organization_id'] !== null ? (int)$userContext['organization_id'] : null; $should = []; // L0 SYSTEM: vendor knowledge (sempre visibile) $should[] = ['key' => 'scope', 'match' => ['value' => 'SYSTEM']]; // L1 FIRM: KB del proprio studio (visibile a tutti i collaboratori) if ($firmId !== null) { $should[] = [ 'must' => [ ['key' => 'scope', 'match' => ['value' => 'FIRM']], ['key' => 'consulting_firm_id', 'match' => ['value' => $firmId]], ], ]; } if ($orgId !== null) { // L1 FIRM con sharing esplicito alla organization corrente $should[] = [ 'must' => [ ['key' => 'scope', 'match' => ['value' => 'FIRM']], ['key' => 'shared_with_orgs', 'match' => ['any' => [$orgId]]], ], ]; // L2 ORG: chunk dell'organizzazione corrente $should[] = [ 'must' => [ ['key' => 'scope', 'match' => ['value' => 'ORG']], ['key' => 'organization_id', 'match' => ['value' => $orgId]], ], ]; } return ['should' => $should]; } /** * @return array{status:int, body:array} */ private function request(string $method, string $path, ?array $body = null): array { $url = $this->qdrantUrl . $path; $ch = curl_init($url); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 30, ]; if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body); } curl_setopt_array($ch, $opts); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [ 'status' => $status, 'body' => $raw ? (json_decode($raw, true) ?? []) : [], ]; } }