284 lines
9.9 KiB
PHP
284 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class WeaviateService
|
|
{
|
|
protected string $host;
|
|
protected ?string $mistralKey;
|
|
protected ?string $weaviateKey;
|
|
|
|
public function __construct()
|
|
{
|
|
$host = config('services.weaviate.host', env('WEAVIATE_HOST', 'http://localhost:8080'));
|
|
$host = trim($host, '"\' ');
|
|
|
|
if ($host && !str_starts_with($host, 'http://') && !str_starts_with($host, 'https://')) {
|
|
$host = 'https://' . $host;
|
|
}
|
|
|
|
$this->host = rtrim($host, '/');
|
|
$this->mistralKey = config('services.mistral.key', env('MISTRAL_API_KEY', ''));
|
|
$this->weaviateKey = config('services.weaviate.api_key', env('WEAVIATE_API_KEY', ''));
|
|
}
|
|
|
|
/**
|
|
* Get headers for Weaviate HTTP requests.
|
|
*/
|
|
protected function getHeaders(): array
|
|
{
|
|
$headers = [
|
|
'Content-Type' => 'application/json',
|
|
];
|
|
|
|
if ($this->mistralKey) {
|
|
$headers['X-Mistral-Api-Key'] = $this->mistralKey;
|
|
}
|
|
|
|
if ($this->weaviateKey) {
|
|
$headers['Authorization'] = 'Bearer ' . $this->weaviateKey;
|
|
$headers['X-Weaviate-Api-Key'] = $this->weaviateKey;
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Check connection and initialize the schema class if it doesn't exist.
|
|
*/
|
|
public function initializeSchema(): bool
|
|
{
|
|
try {
|
|
// Check if class already exists
|
|
$response = Http::withHeaders($this->getHeaders())
|
|
->get("{$this->host}/v1/schema/DocumentChunk");
|
|
|
|
if ($response->successful()) {
|
|
// If it exists, let's verify if the property chatbotId exists
|
|
$data = $response->json();
|
|
$hasChatbotId = false;
|
|
if (isset($data['properties'])) {
|
|
foreach ($data['properties'] as $prop) {
|
|
if ($prop['name'] === 'chatbotId') {
|
|
$hasChatbotId = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If schema exists but doesn't have chatbotId property, we add it
|
|
if (!$hasChatbotId) {
|
|
$newProp = [
|
|
'name' => 'chatbotId',
|
|
'dataType' => ['text'],
|
|
'description' => 'The unique ID of the chatbot/client',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'skip' => true
|
|
]
|
|
]
|
|
];
|
|
Http::withHeaders($this->getHeaders())
|
|
->post("{$this->host}/v1/schema/DocumentChunk/properties", $newProp);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if ($response->status() === 404) {
|
|
// Class doesn't exist, create it
|
|
$schema = [
|
|
'class' => 'DocumentChunk',
|
|
'description' => 'A chunk of text from a web page or PDF document',
|
|
'vectorizer' => 'text2vec-mistral',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'model' => 'mistral-embed',
|
|
'vectorizeClassName' => false
|
|
]
|
|
],
|
|
'properties' => [
|
|
[
|
|
'name' => 'chatbotId',
|
|
'dataType' => ['text'],
|
|
'description' => 'The unique ID of the chatbot/client',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'skip' => true
|
|
]
|
|
]
|
|
],
|
|
[
|
|
'name' => 'content',
|
|
'dataType' => ['text'],
|
|
'description' => 'The actual text content of the chunk',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'skip' => false,
|
|
'vectorizePropertyName' => false
|
|
]
|
|
]
|
|
],
|
|
[
|
|
'name' => 'source',
|
|
'dataType' => ['text'],
|
|
'description' => 'The URL or file path of the source document',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'skip' => true
|
|
]
|
|
]
|
|
],
|
|
[
|
|
'name' => 'title',
|
|
'dataType' => ['text'],
|
|
'description' => 'The title of the source document',
|
|
'moduleConfig' => [
|
|
'text2vec-mistral' => [
|
|
'skip' => true
|
|
]
|
|
]
|
|
]
|
|
]
|
|
];
|
|
|
|
$createResponse = Http::withHeaders($this->getHeaders())
|
|
->post("{$this->host}/v1/schema", $schema);
|
|
|
|
if ($createResponse->successful()) {
|
|
Log::info("Weaviate schema DocumentChunk initialized successfully.");
|
|
return true;
|
|
}
|
|
|
|
Log::error("Failed to create Weaviate schema: " . $createResponse->body());
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
} catch (\Exception $e) {
|
|
Log::error("Weaviate schema initialization error: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Index a single chunk or multiple chunks of documents for a specific chatbot.
|
|
*/
|
|
public function indexChunks(array $chunks, string $chatbotId): bool
|
|
{
|
|
$this->initializeSchema();
|
|
|
|
try {
|
|
$objects = [];
|
|
foreach ($chunks as $chunk) {
|
|
$objects[] = [
|
|
'class' => 'DocumentChunk',
|
|
'properties' => [
|
|
'chatbotId' => $chatbotId,
|
|
'content' => $chunk['content'],
|
|
'source' => $chunk['source'],
|
|
'title' => $chunk['title'] ?? 'Document',
|
|
]
|
|
];
|
|
}
|
|
|
|
$response = Http::withHeaders($this->getHeaders())
|
|
->post("{$this->host}/v1/batch/objects", [
|
|
'objects' => $objects
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return true;
|
|
}
|
|
|
|
Log::error("Failed to batch index chunks to Weaviate: " . $response->body());
|
|
return false;
|
|
} catch (\Exception $e) {
|
|
Log::error("Weaviate batch index error: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all indexed documents for a specific chatbot.
|
|
*/
|
|
public function clearChatbotData(string $chatbotId): bool
|
|
{
|
|
try {
|
|
$payload = [
|
|
'match' => [
|
|
'class' => 'DocumentChunk',
|
|
'where' => [
|
|
'path' => ['chatbotId'],
|
|
'operator' => 'Equal',
|
|
'valueText' => $chatbotId
|
|
]
|
|
]
|
|
];
|
|
|
|
$response = Http::withHeaders($this->getHeaders())
|
|
->delete("{$this->host}/v1/batch/objects", $payload);
|
|
|
|
return $response->successful();
|
|
} catch (\Exception $e) {
|
|
Log::error("Weaviate batch delete error: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query Weaviate for similar text chunks, scoped to a specific chatbot.
|
|
*/
|
|
public function search(string $query, string $chatbotId, int $limit = 5): array
|
|
{
|
|
$this->initializeSchema();
|
|
try {
|
|
$graphqlQuery = [
|
|
'query' => '
|
|
{
|
|
Get {
|
|
DocumentChunk(
|
|
nearText: {
|
|
concepts: ["' . addslashes($query) . '"]
|
|
}
|
|
where: {
|
|
path: ["chatbotId"]
|
|
operator: Equal
|
|
valueText: "' . addslashes($chatbotId) . '"
|
|
}
|
|
limit: ' . $limit . '
|
|
) {
|
|
content
|
|
source
|
|
title
|
|
_additional {
|
|
distance
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'
|
|
];
|
|
|
|
$response = Http::withHeaders($this->getHeaders())
|
|
->post("{$this->host}/v1/graphql", $graphqlQuery);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
$results = $data['data']['Get']['DocumentChunk'] ?? [];
|
|
Log::info("Weaviate search for chatbot {$chatbotId} returned " . count($results) . " chunks.");
|
|
return $results;
|
|
}
|
|
|
|
Log::error("Weaviate GraphQL search failed: " . $response->body());
|
|
return [];
|
|
} catch (\Exception $e) {
|
|
Log::error("Weaviate search error: " . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
}
|