Initial commit

This commit is contained in:
jeremy bayse
2026-06-22 16:40:24 +02:00
commit ad127156ff
78 changed files with 13478 additions and 0 deletions
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Chatbot;
use App\Services\DocumentIngestionService;
use Illuminate\Console\Command;
class IngestDocumentCommand extends Command
{
protected $signature = 'ingest:document {--url= : URL to scrape and ingest} {--pdf= : Path to PDF file to ingest} {--chatbot= : Slug of the chatbot to associate with}';
protected $description = 'Ingests a URL or PDF document into Weaviate vector DB for a specific chatbot';
public function handle(DocumentIngestionService $ingestionService)
{
$url = $this->option('url');
$pdfPath = $this->option('pdf');
$chatbotSlug = $this->option('chatbot');
if (!$chatbotSlug) {
$this->error('Please specify the chatbot using --chatbot=slug option.');
return 1;
}
$chatbot = Chatbot::where('slug', $chatbotSlug)->first();
if (!$chatbot) {
$this->error("Chatbot not found for slug: {$chatbotSlug}");
return 1;
}
if (!$url && !$pdfPath) {
$this->error('Please provide either a --url or a --pdf option.');
return 1;
}
$chatbotId = (string) $chatbot->id;
if ($url) {
$this->info("Ingesting URL: {$url} for chatbot '{$chatbot->name}'...");
$success = $ingestionService->ingestUrl($url, $chatbotId);
if ($success) {
$this->info("Successfully ingested URL!");
$chatbot->histories()->create([
'action' => 'Indexation URL',
'target' => $url,
'status' => 'success'
]);
} else {
$this->error("Failed to ingest URL.");
$chatbot->histories()->create([
'action' => 'Indexation URL',
'target' => $url,
'status' => 'failed'
]);
return 1;
}
}
if ($pdfPath) {
if (!file_exists($pdfPath)) {
$this->error("PDF file not found at: {$pdfPath}");
return 1;
}
$originalName = basename($pdfPath);
$this->info("Ingesting PDF: {$originalName} for chatbot '{$chatbot->name}'...");
$success = $ingestionService->ingestPdf($pdfPath, $originalName, $chatbotId);
if ($success) {
$this->info("Successfully ingested PDF!");
$chatbot->histories()->create([
'action' => 'Indexation PDF',
'target' => $originalName,
'status' => 'success'
]);
} else {
$this->error("Failed to ingest PDF.");
$chatbot->histories()->create([
'action' => 'Indexation PDF',
'target' => $originalName,
'status' => 'failed'
]);
return 1;
}
}
return 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot;
use App\Models\ChatbotHistory;
use App\Services\DocumentIngestionService;
use App\Services\WeaviateService;
use Livewire\Component;
use Livewire\WithFileUploads;
class AdminDashboard extends Component
{
use WithFileUploads;
public Chatbot $chatbot;
public string $url = '';
public $pdfFile;
public string $statusMessage = '';
public string $statusType = ''; // 'success', 'error', 'info'
public string $systemPrompt = '';
public string $chatbotName = '';
public array $history = [];
public bool $enableVision = true;
public bool $crawlEntireSite = false;
public int $maxPages = 30;
public function mount(Chatbot $chatbot)
{
$this->chatbot = $chatbot;
$this->systemPrompt = $chatbot->system_prompt ?? '';
$this->chatbotName = $chatbot->name;
$this->loadHistory();
}
protected function loadHistory()
{
$this->history = $this->chatbot->histories()
->latest()
->limit(15)
->get()
->map(function ($item) {
return [
'action' => $item->action,
'target' => $item->target,
'status' => $item->status,
'timestamp' => $item->created_at->format('d/m/Y H:i')
];
})->toArray();
}
public function saveSystemPrompt()
{
$this->validate([
'systemPrompt' => 'required|string|max:5000',
'chatbotName' => 'required|string|max:100',
]);
$this->chatbot->update([
'name' => $this->chatbotName,
'system_prompt' => $this->systemPrompt
]);
$this->logAction("Configuration Chatbot", "Nom modifié en '{$this->chatbotName}'", "success");
$this->statusMessage = "Les paramètres du chatbot ont été mis à jour avec succès !";
$this->statusType = 'success';
$this->dispatch('settings-updated');
}
protected function logAction(string $action, string $target, string $status)
{
$this->chatbot->histories()->create([
'action' => $action,
'target' => $target,
'status' => $status
]);
$this->loadHistory();
}
public function ingestUrl(DocumentIngestionService $ingestionService)
{
$this->validate([
'url' => 'required|url',
'maxPages' => 'required|integer|min:1|max:100',
]);
if ($this->crawlEntireSite) {
$this->statusMessage = "Exploration et indexation de l'ensemble du site en cours (cela peut prendre quelques minutes)...";
$this->statusType = 'info';
// Increase time limit for crawler execution
@set_time_limit(300);
$indexedCount = $ingestionService->crawlAndIngestSite($this->url, (string) $this->chatbot->id, $this->maxPages);
if ($indexedCount > 0) {
$this->logAction("Crawler Site Web", "{$this->url} ({$indexedCount} pages indexées)", "success");
$this->statusMessage = "Le site a été exploré et {$indexedCount} pages ont été indexées avec succès !";
$this->statusType = 'success';
$this->url = '';
} else {
$this->logAction("Crawler Site Web", $this->url, "failed");
$this->statusMessage = "L'exploration du site a échoué. Veuillez vérifier les logs et l'URL.";
$this->statusType = 'error';
}
} else {
$this->statusMessage = "Indexation de l'URL en cours...";
$this->statusType = 'info';
$success = $ingestionService->ingestUrl($this->url, (string) $this->chatbot->id);
if ($success) {
$this->logAction("Indexation URL", $this->url, "success");
$this->statusMessage = "L'URL a été indexée avec succès dans Weaviate !";
$this->statusType = 'success';
$this->url = '';
} else {
$this->logAction("Indexation URL", $this->url, "failed");
$this->statusMessage = "Impossible d'indexer l'URL. Veuillez vérifier les logs et l'état de Weaviate.";
$this->statusType = 'error';
}
}
}
public function ingestPdf(DocumentIngestionService $ingestionService)
{
$this->validate([
'pdfFile' => 'required|file|mimes:pdf|max:10240', // Max 10MB
]);
$this->statusMessage = "Indexation du fichier PDF en cours...";
$this->statusType = 'info';
$originalName = $this->pdfFile->getClientOriginalName();
$storedPath = $this->pdfFile->storeAs('temp', $originalName);
$absolutePath = storage_path('app/private/' . $storedPath);
if (!file_exists($absolutePath)) {
$absolutePath = storage_path('app/' . $storedPath);
}
$success = $ingestionService->ingestPdf($absolutePath, $originalName, (string) $this->chatbot->id, $this->enableVision);
// Cleanup
if (file_exists($absolutePath)) {
@unlink($absolutePath);
}
if ($success) {
$this->logAction("Indexation PDF", $originalName, "success");
$this->statusMessage = "Le fichier PDF '{$originalName}' a été indexé avec succès !";
$this->statusType = 'success';
$this->pdfFile = null;
} else {
$this->logAction("Indexation PDF", $originalName, "failed");
$this->statusMessage = "Une erreur s'est produite lors de l'indexation du PDF.";
$this->statusType = 'error';
}
}
public function clearDatabase(WeaviateService $weaviateService)
{
$success = $weaviateService->clearChatbotData((string) $this->chatbot->id);
if ($success) {
$this->logAction("Réinitialisation Index", "Suppression de toutes les données vectorielles", "success");
$this->statusMessage = "La base de données vectorielle du chatbot a été réinitialisée avec succès.";
$this->statusType = 'success';
} else {
$this->logAction("Réinitialisation Index", "Suppression de toutes les données vectorielles", "failed");
$this->statusMessage = "Impossible de vider les données Weaviate.";
$this->statusType = 'error';
}
}
public function render()
{
return view('livewire.admin-dashboard');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot as ChatbotModel;
use App\Services\MistralService;
use App\Services\WeaviateService;
use Livewire\Component;
use Livewire\Attributes\On;
class Chatbot extends Component
{
public ChatbotModel $chatbot;
public string $userMessage = '';
public array $messages = [];
public bool $isSending = false;
public string $chatbotName = '';
public function mount(ChatbotModel $chatbot)
{
$this->chatbot = $chatbot;
$this->loadSettings();
// Load messages from session specific to this chatbot's slug
$this->messages = session()->get("chat_messages_{$chatbot->slug}", [
[
'role' => 'assistant',
'content' => "Bonjour ! Je suis votre assistant virtuel RAG. Je peux répondre à vos questions en me basant sur le contenu de ce site. Que puis-je faire pour vous aujourd'hui ?"
]
]);
}
#[On('settings-updated')]
public function loadSettings()
{
// Reload chatbot model from database to get fresh settings
$this->chatbot->refresh();
$this->chatbotName = $this->chatbot->name;
}
public function sendMessage(WeaviateService $weaviate, MistralService $mistral)
{
$this->validate([
'userMessage' => 'required|string|max:1000',
]);
$query = trim($this->userMessage);
// 1. Add user message
$this->messages[] = [
'role' => 'user',
'content' => $query
];
$this->userMessage = '';
$this->isSending = true;
// Save to session immediately for visual update
session()->put("chat_messages_{$this->chatbot->slug}", $this->messages);
$this->dispatch('message-sent');
}
public function getResponse(WeaviateService $weaviate, MistralService $mistral)
{
if (!$this->isSending) {
return;
}
$lastMessage = end($this->messages);
if (!$lastMessage || $lastMessage['role'] !== 'user') {
$this->isSending = false;
return;
}
$query = $lastMessage['content'];
// 2. Search Weaviate for context using the chatbot ID
$chunks = $weaviate->search($query, (string) $this->chatbot->id, 4);
// 3. Generate response via Mistral AI
$history = array_slice($this->messages, 0, -1);
$response = $mistral->generateResponse($query, $chunks, $history, $this->chatbot->system_prompt);
// Extract sources
$sources = [];
if (!empty($chunks)) {
foreach ($chunks as $chunk) {
if (isset($chunk['source'])) {
$sources[] = $chunk['source'];
}
}
}
$sources = array_unique($sources);
// 4. Add assistant response
$this->messages[] = [
'role' => 'assistant',
'content' => $response ?? "Désolé, je n'ai pas pu obtenir de réponse.",
'sources' => $sources
];
$this->isSending = false;
// Save to session
session()->put("chat_messages_{$this->chatbot->slug}", $this->messages);
}
public function clearChat()
{
session()->forget("chat_messages_{$this->chatbot->slug}");
$this->mount($this->chatbot);
}
public function render()
{
return view('livewire.chatbot');
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Livewire;
use App\Models\Chatbot;
use App\Services\WeaviateService;
use Illuminate\Support\Str;
use Livewire\Component;
class ChatbotManager extends Component
{
public string $name = '';
public string $slug = '';
public string $statusMessage = '';
public string $statusType = '';
public function createChatbot()
{
// Auto-generate slug if empty
if (empty($this->slug)) {
$this->slug = Str::slug($this->name);
} else {
$this->slug = Str::slug($this->slug);
}
$this->validate([
'name' => 'required|string|max:100',
'slug' => 'required|string|unique:chatbots,slug|max:100',
]);
$chatbot = Chatbot::create([
'name' => $this->name,
'slug' => $this->slug,
]);
$this->name = '';
$this->slug = '';
$this->statusMessage = "Le chatbot '{$chatbot->name}' a été créé avec succès !";
$this->statusType = 'success';
}
public function deleteChatbot(int $id, WeaviateService $weaviateService)
{
$chatbot = Chatbot::find($id);
if ($chatbot) {
$name = $chatbot->name;
// Delete data in Weaviate for this chatbot
$weaviateService->clearChatbotData((string) $chatbot->id);
// Delete database records
$chatbot->delete();
$this->statusMessage = "Le chatbot '{$name}' et toutes ses données vectorielles ont été supprimés.";
$this->statusType = 'success';
}
}
public function render()
{
$chatbots = Chatbot::latest()->get();
return view('livewire.chatbot-manager', compact('chatbots'));
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Chatbot extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'system_prompt',
];
/**
* Get histories associated with this chatbot.
*/
public function histories(): HasMany
{
return $this->hasMany(ChatbotHistory::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatbotHistory extends Model
{
use HasFactory;
protected $fillable = [
'chatbot_id',
'action',
'target',
'status',
];
/**
* Get the chatbot associated with this history record.
*/
public function chatbot(): BelongsTo
{
return $this->belongsTo(Chatbot::class);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
class DocumentIngestionService
{
protected WeaviateService $weaviate;
public function __construct(WeaviateService $weaviate)
{
$this->weaviate = $weaviate;
}
/**
* Ingest content from a URL for a specific chatbot.
*/
public function ingestUrl(string $url, string $chatbotId): bool
{
try {
$response = Http::timeout(15)->get($url);
if (!$response->successful()) {
Log::error("Failed to fetch URL for ingestion: {$url}");
return false;
}
$html = $response->body();
// Extract Title
preg_match('/<title>(.*?)<\/title>/is', $html, $matches);
$title = isset($matches[1]) ? trim($matches[1]) : $url;
// Extract Clean Text Content
$text = $this->cleanHtml($html);
if (empty($text)) {
Log::warning("No text extracted from URL: {$url}");
return false;
}
// Chunk and Index
$chunks = $this->chunkText($text, 800, 150);
$formattedChunks = [];
foreach ($chunks as $chunk) {
$formattedChunks[] = [
'content' => $chunk,
'source' => $url,
'title' => $title
];
}
return $this->weaviate->indexChunks($formattedChunks, $chatbotId);
} catch (\Exception $e) {
Log::error("Error ingesting URL {$url} for chatbot {$chatbotId}: " . $e->getMessage());
return false;
}
}
/**
* Crawl and ingest an entire website starting from a base URL.
*/
public function crawlAndIngestSite(string $baseUrl, string $chatbotId, int $maxPages = 50): int
{
$parsedBase = parse_url($baseUrl);
$baseHost = $parsedBase['host'] ?? '';
$baseScheme = $parsedBase['scheme'] ?? 'http';
if (empty($baseHost)) {
Log::error("Invalid base URL for site crawl: {$baseUrl}");
return 0;
}
$queue = [$baseUrl];
$visited = [];
$indexedCount = 0;
Log::info("Starting site crawl of {$baseUrl} for chatbot {$chatbotId} (max pages: {$maxPages})");
while (!empty($queue) && $indexedCount < $maxPages) {
$url = array_shift($queue);
// Normalize URL
$url = strtok($url, '#');
$url = rtrim($url, '/');
if (isset($visited[$url])) {
continue;
}
$visited[$url] = true;
try {
Log::info("Crawler visiting URL [{$indexedCount}/{$maxPages}]: {$url}");
$response = Http::timeout(10)->get($url);
if (!$response->successful()) {
Log::warning("Crawler failed to fetch URL: {$url}");
continue;
}
// Check if response is HTML
$contentType = $response->header('Content-Type');
if ($contentType && strpos($contentType, 'text/html') === false) {
Log::info("Crawler skipping non-HTML response at URL: {$url}");
continue;
}
$html = $response->body();
// Extract Title
preg_match('/<title>(.*?)<\/title>/is', $html, $matches);
$title = isset($matches[1]) ? trim($matches[1]) : $url;
// Extract Clean Text Content
$text = $this->cleanHtml($html);
if (!empty($text)) {
$chunks = $this->chunkText($text, 800, 150);
$formattedChunks = [];
foreach ($chunks as $chunk) {
$formattedChunks[] = [
'content' => $chunk,
'source' => $url,
'title' => $title
];
}
if (!empty($formattedChunks)) {
$this->weaviate->indexChunks($formattedChunks, $chatbotId);
$indexedCount++;
}
}
// Discover links
preg_match_all('/<a\s+[^>]*href="([^"]+)"/i', $html, $linkMatches);
if (isset($linkMatches[1])) {
foreach ($linkMatches[1] as $link) {
$resolvedLink = $this->resolveUrl($link, $url);
if (!$resolvedLink) {
continue;
}
$parsedLink = parse_url($resolvedLink);
$linkHost = $parsedLink['host'] ?? '';
// Only follow same host links using http/https protocols
if ($linkHost === $baseHost && in_array($parsedLink['scheme'] ?? '', ['http', 'https'])) {
$resolvedLink = strtok($resolvedLink, '#');
$resolvedLink = rtrim($resolvedLink, '/');
if (!isset($visited[$resolvedLink]) && !in_array($resolvedLink, $queue)) {
$queue[] = $resolvedLink;
}
}
}
}
} catch (\Exception $e) {
Log::error("Crawler error visiting URL {$url}: " . $e->getMessage());
}
}
Log::info("Crawler finished. Indexed {$indexedCount} pages.");
return $indexedCount;
}
/**
* Resolve a relative URL relative to a base URL.
*/
protected function resolveUrl(string $rel, string $base): ?string
{
if (preg_match('/^(mailto|tel|javascript|#):/i', $rel) || strpos($rel, '#') === 0) {
return null;
}
if (parse_url($rel, PHP_URL_SCHEME) != '') {
return $rel;
}
$baseParts = parse_url($base);
$scheme = $baseParts['scheme'] ?? 'http';
$host = $baseParts['host'] ?? '';
$port = isset($baseParts['port']) ? ':' . $baseParts['port'] : '';
$path = $baseParts['path'] ?? '/';
if (strpos($rel, '//') === 0) {
return $scheme . ':' . $rel;
}
if (strpos($rel, '/') === 0) {
return $scheme . '://' . $host . $port . $rel;
}
$dir = preg_replace('/\/[^\/]*$/', '', $path);
return $scheme . '://' . $host . $port . $dir . '/' . $rel;
}
/**
* Ingest content from a PDF file for a specific chatbot using the Python script.
*/
public function ingestPdf(string $filePath, string $originalName, string $chatbotId, bool $enableVision = false): bool
{
try {
if (!file_exists($filePath)) {
Log::error("PDF file not found: {$filePath}");
return false;
}
$scriptPath = base_path('scripts/parse_pdf.py');
$mistralKey = config('services.mistral.key') ?? env('MISTRAL_API_KEY', '');
$visionFlag = $enableVision ? '1' : '0';
// Run Python script
$result = Process::run([
'python',
$scriptPath,
$filePath,
$mistralKey,
$visionFlag
]);
if (!$result->successful()) {
Log::error("Python PDF parser script failed: " . $result->errorOutput());
return false;
}
$chunks = json_decode($result->output(), true);
if (isset($chunks['error'])) {
Log::error("Python PDF parser error: " . $chunks['error']);
return false;
}
if (empty($chunks)) {
Log::warning("No text chunks extracted from PDF: {$originalName}");
return false;
}
return $this->weaviate->indexChunks($chunks, $chatbotId);
} catch (\Exception $e) {
Log::error("Error ingesting PDF {$originalName} for chatbot {$chatbotId}: " . $e->getMessage());
return false;
}
}
/**
* Clean and strip HTML down to readability.
*/
protected function cleanHtml(string $html): string
{
// Remove scripts, styles, header, footer, nav
$html = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $html);
$html = preg_replace('/<style\b[^>]*>(.*?)<\/style>/is', '', $html);
$html = preg_replace('/<header\b[^>]*>(.*?)<\/header>/is', '', $html);
$html = preg_replace('/<footer\b[^>]*>(.*?)<\/footer>/is', '', $html);
$html = preg_replace('/<nav\b[^>]*>(.*?)<\/nav>/is', '', $html);
// Strip tags
$text = strip_tags($html);
// Remove duplicate whitespace and newlines
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace('/\s*\n\s*/', "\n", $text);
$text = preg_replace('/\n+/', "\n\n", $text);
return trim($text);
}
/**
* Chunk text using sliding window.
*/
protected function chunkText(string $text, int $chunkSize = 800, int $overlap = 150): array
{
$words = explode(' ', $text);
$chunks = [];
$currentChunk = [];
$currentLength = 0;
foreach ($words as $word) {
$currentChunk[] = $word;
$currentLength += strlen($word) + 1; // +1 for space
if ($currentLength >= $chunkSize) {
$chunks[] = implode(' ', $currentChunk);
// Slide the window back by keeping some words for overlap
$overlapWordsCount = (int) ($overlap / 6); // estimate 6 chars per word
if ($overlapWordsCount > 0 && count($currentChunk) > $overlapWordsCount) {
$currentChunk = array_slice($currentChunk, -$overlapWordsCount);
$currentLength = strlen(implode(' ', $currentChunk));
} else {
$currentChunk = [];
$currentLength = 0;
}
}
}
// Add remaining text
if (!empty($currentChunk)) {
$chunks[] = implode(' ', $currentChunk);
}
return array_filter($chunks, function ($chunk) {
return strlen(trim($chunk)) > 50; // Filter out tiny chunks
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MistralService
{
protected string $apiKey;
protected string $model;
public function __construct()
{
$this->apiKey = config('services.mistral.key') ?? env('MISTRAL_API_KEY', '');
$this->model = config('services.mistral.model') ?? env('MISTRAL_MODEL', 'mistral-small-latest');
}
/**
* Generate a response using RAG context and chat history.
*/
public function generateResponse(string $question, array $contextChunks, array $chatHistory = [], ?string $systemPrompt = null): ?string
{
if (empty($this->apiKey)) {
Log::error("Mistral API key is not configured.");
return "Désolé, la clé API Mistral AI n'est pas configurée dans l'application.";
}
// Format context from chunks
$contextText = "";
if (!empty($contextChunks)) {
$contextText = "Voici le contexte extrait des documents et sites internet indexés :\n\n";
foreach ($contextChunks as $index => $chunk) {
$num = $index + 1;
$title = $chunk['title'] ?? 'Document';
$source = $chunk['source'] ?? 'N/A';
$contextText .= "[Document #{$num} : {$title} (Source: {$source})]\n{$chunk['content']}\n\n";
}
} else {
$contextText = "Aucun contexte spécifique n'a été trouvé.";
}
// Prepare system message
if (empty($systemPrompt)) {
$systemPrompt = "Tu es un assistant virtuel intelligent et chaleureux intégré au site internet. Ton but est d'aider les utilisateurs en répondant de manière claire, concise et professionnelle.\n\n";
$systemPrompt .= "INSTRUCTIONS DE SÉCURITÉ CRITIQUES :\n";
$systemPrompt .= "1. Réponds UNIQUEMENT et STRICTEMENT en t'appuyant sur le CONTEXTE fourni ci-dessous.\n";
$systemPrompt .= "2. Si le contexte ne contient pas l'information nécessaire pour répondre à la question, réponds poliment que tu ne disposes pas de cette information car elle ne figure pas dans les documents indexés. N'utilise JAMAIS tes connaissances générales pour inventer, deviner ou extrapoler une réponse en dehors du contexte.\n";
$systemPrompt .= "3. Ne fais aucune supposition et n'invente aucune information. Si le contexte est vide ou insuffisant, refuse de répondre en expliquant que l'information n'est pas répertoriée dans les sources indexées.\n";
$systemPrompt .= "4. Réponds toujours en français, de manière structurée.\n";
$systemPrompt .= "5. Cite obligatoirement la source des documents fournis qui t'ont permis de répondre (ex: 'D'après le document [Titre]...').\n\n";
}
$systemPrompt .= "\n\n--- CONTEXTE DE RECHERCHE ---\n";
$systemPrompt .= $contextText;
// Build messages array
$messages = [
['role' => 'system', 'content' => $systemPrompt]
];
// Append chat history (limit to last 6 messages to keep context window clean)
$recentHistory = array_slice($chatHistory, -6);
foreach ($recentHistory as $msg) {
$messages[] = [
'role' => $msg['role'] === 'user' ? 'user' : 'assistant',
'content' => $msg['content']
];
}
// Add the current user question
$messages[] = ['role' => 'user', 'content' => $question];
try {
$response = Http::withHeaders([
'Authorization' => "Bearer {$this->apiKey}",
'Content-Type' => 'application/json'
])->timeout(30)->post('https://api.mistral.ai/v1/chat/completions', [
'model' => $this->model,
'messages' => $messages,
'temperature' => 0.3, // Low temperature for more factual answers
'max_tokens' => 1000
]);
if ($response->successful()) {
$result = $response->json();
return $result['choices'][0]['message']['content'] ?? null;
}
Log::error("Mistral API Chat Completion failed: " . $response->body());
return "Une erreur s'est produite lors de la génération de la réponse par Mistral AI (Code status: " . $response->status() . ").";
} catch (\Exception $e) {
Log::error("Mistral Service Error: " . $e->getMessage());
return "Impossible de contacter le service de génération de réponses Mistral. Veuillez réessayer.";
}
}
}
+283
View File
@@ -0,0 +1,283 @@
<?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 [];
}
}
}