309 lines
11 KiB
PHP
309 lines
11 KiB
PHP
<?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';
|
|
|
|
// Set execution time limit for PHP to 600 seconds
|
|
@set_time_limit(600);
|
|
|
|
// Run Python script with a 600-second timeout
|
|
$result = Process::timeout(600)->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
|
|
});
|
|
}
|
|
}
|