Build Laravel 13 ticket assistant with Docker, Livewire admin, and helpdesk scraper command

This commit is contained in:
SitiWeb
2026-04-29 13:11:39 +02:00
parent 141a1a3c9b
commit 3c4572bb12
58 changed files with 9377 additions and 455 deletions

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\DTOs\ArticleCandidateDTO;
use App\DTOs\ClassificationResultDTO;
use App\Exceptions\OllamaUnavailableException;
use Illuminate\Support\Facades\Http;
use Throwable;
class AIClassifierService
{
/** @param array<ArticleCandidateDTO> $candidates */
public function rank(string $ticketMessage, array $candidates): ClassificationResultDTO
{
if ($candidates === []) {
return new ClassificationResultDTO(null, 0.0, 'No article candidates available');
}
$articlesBlock = collect($candidates)
->map(fn (ArticleCandidateDTO $item, int $idx) => sprintf(
"%d. article_id=%d\nTitle: %s\nContent: %s",
$idx + 1,
$item->articleId,
$item->title,
$item->content
))
->implode("\n\n");
$prompt = <<<PROMPT
You are a support assistant.
User question:
"{$ticketMessage}"
Articles:
{$articlesBlock}
Task:
- Select the best matching article
- Return:
- article_id
- confidence (0-1)
- short explanation
Respond in JSON ONLY.
PROMPT;
$baseUrl = rtrim((string) config('services.ollama.base_url'), '/');
try {
$response = Http::timeout((int) config('services.ollama.timeout', 30))
->post($baseUrl.'/api/generate', [
'model' => config('services.ollama.chat_model', 'llama3'),
'prompt' => $prompt,
'stream' => false,
])
->throw()
->json();
} catch (Throwable $e) {
throw new OllamaUnavailableException('Ollama generate endpoint is unavailable', 0, $e);
}
$text = (string) ($response['response'] ?? '{}');
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
return new ClassificationResultDTO(
articleId: $candidates[0]->articleId,
confidence: 0.35,
explanation: 'LLM returned invalid JSON; defaulted to top semantic match.',
rawResponse: ['raw' => $text]
);
}
return new ClassificationResultDTO(
articleId: isset($decoded['article_id']) ? (int) $decoded['article_id'] : $candidates[0]->articleId,
confidence: isset($decoded['confidence']) ? (float) $decoded['confidence'] : 0.35,
explanation: (string) ($decoded['explanation'] ?? 'No explanation provided.'),
rawResponse: $decoded
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Services;
use App\Models\Article;
class AdminArticleService
{
public function paginate(int $perPage = 10)
{
return Article::query()->latest()->paginate($perPage);
}
public function create(string $title, string $content): Article
{
return Article::query()->create([
'title' => trim($title),
'content' => trim($content),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Models\AIDecision;
use App\Models\Article;
use App\Models\Feedback;
use App\Models\Ticket;
class AdminDashboardService
{
public function stats(): array
{
return [
'articles_count' => Article::query()->count(),
'tickets_count' => Ticket::query()->count(),
'decisions_count' => AIDecision::query()->count(),
'feedback_accuracy' => $this->feedbackAccuracy(),
];
}
public function recentTickets(int $limit = 10)
{
return Ticket::query()->latest()->limit($limit)->get();
}
public function recentDecisions(int $limit = 10)
{
return AIDecision::query()->with(['ticket', 'article'])->latest()->limit($limit)->get();
}
private function feedbackAccuracy(): ?float
{
$total = Feedback::query()->count();
if ($total === 0) {
return null;
}
$correct = Feedback::query()->where('is_correct', true)->count();
return round(($correct / $total) * 100, 2);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services;
use App\Models\Ticket;
class AdminTicketService
{
public function paginateWithDecision(int $perPage = 10)
{
return Ticket::query()
->with(['decisions.article'])
->latest()
->paginate($perPage);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\Exceptions\OllamaUnavailableException;
use App\Models\EmbeddingCache;
use Illuminate\Support\Facades\Http;
use Throwable;
class EmbeddingService
{
public function embed(string $text): array
{
$hash = hash('sha256', $text);
$cached = EmbeddingCache::query()->where('text_hash', $hash)->first();
if ($cached !== null) {
return $cached->embedding;
}
$baseUrl = rtrim((string) config('services.ollama.base_url'), '/');
try {
$response = Http::timeout((int) config('services.ollama.timeout', 30))
->post($baseUrl.'/api/embeddings', [
'model' => config('services.ollama.embed_model', 'nomic-embed-text'),
'prompt' => $text,
])
->throw()
->json();
} catch (Throwable $e) {
throw new OllamaUnavailableException('Ollama embedding endpoint is unavailable', 0, $e);
}
$embedding = $response['embedding'] ?? [];
if (!is_array($embedding) || $embedding === []) {
throw new OllamaUnavailableException('Ollama embedding response did not include a valid embedding');
}
EmbeddingCache::query()->updateOrCreate(
['text_hash' => $hash],
['text' => $text, 'embedding' => $embedding]
);
return $embedding;
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace App\Services;
use App\Models\Article;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class HelpdeskImportService
{
private const DEFAULT_BASE_URL = 'https://www.internettoday.nl/helpdesk';
public function import(?string $baseUrl = null, bool $dryRun = false, ?int $limit = null): array
{
$baseUrl = rtrim($baseUrl ?: self::DEFAULT_BASE_URL, '/');
$rootHtml = $this->fetch($baseUrl);
$categories = $this->extractCategories($rootHtml);
$sectionUrls = $this->buildSectionUrls($baseUrl, $categories);
$articleUrls = $this->collectArticleUrls($baseUrl, $rootHtml, $sectionUrls);
if ($limit !== null && $limit > 0) {
$articleUrls = array_slice($articleUrls, 0, $limit);
}
$imported = 0;
$updated = 0;
$skipped = 0;
foreach ($articleUrls as $articleUrl) {
$parsed = $this->parseArticlePage($articleUrl);
if ($parsed === null) {
$skipped++;
continue;
}
if ($dryRun) {
$imported++;
continue;
}
[$title, $content] = $parsed;
$result = Article::withoutEvents(function () use ($title, $content) {
return Article::query()->updateOrCreate(
['title' => $title],
['content' => $content]
);
});
if ($result->wasRecentlyCreated) {
$imported++;
} else {
$updated++;
}
}
return [
'categories' => count($categories),
'sections' => count($sectionUrls),
'article_urls' => count($articleUrls),
'imported' => $imported,
'updated' => $updated,
'skipped' => $skipped,
'dry_run' => $dryRun,
];
}
private function fetch(string $url): string
{
return Http::timeout(30)
->retry(2, 300)
->get($url)
->throw()
->body();
}
private function extractCategories(string $html): array
{
if (!preg_match('/const\s+categories\s*=\s*(\[.*?\]);/s', $html, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
return is_array($decoded) ? $decoded : [];
}
private function buildSectionUrls(string $baseUrl, array $categories): array
{
$urls = [];
foreach ($categories as $category) {
if (!isset($category['id'], $category['slug'])) {
continue;
}
$urls[] = sprintf('%s/%d/%s', $baseUrl, (int) $category['id'], (string) $category['slug']);
foreach (($category['children'] ?? []) as $child) {
if (!isset($child['id'], $child['slug'])) {
continue;
}
$urls[] = sprintf('%s/%d/%s', $baseUrl, (int) $child['id'], (string) $child['slug']);
}
}
return array_values(array_unique($urls));
}
private function collectArticleUrls(string $baseUrl, string $rootHtml, array $sectionUrls): array
{
$urls = [];
foreach (array_merge([$baseUrl], $sectionUrls) as $url) {
try {
$html = $url === $baseUrl ? $rootHtml : $this->fetch($url);
} catch (\Throwable) {
continue;
}
preg_match_all('/https:\/\/www\.internettoday\.nl\/helpdesk\/(\d+)-[a-z0-9\-]+/i', $html, $matches);
foreach (($matches[0] ?? []) as $match) {
$urls[] = strtolower($match);
}
}
return array_values(array_unique($urls));
}
private function parseArticlePage(string $url): ?array
{
try {
$html = $this->fetch($url);
} catch (\Throwable) {
return null;
}
if (!preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $titleMatch)) {
return null;
}
$title = $this->sanitizeText($titleMatch[1]);
if ($title === '') {
return null;
}
if (!preg_match('/<div\s+class="main_1_column">\s*(<p.*?<\/p>)\s*<\/div>/is', $html, $contentMatch)) {
return null;
}
$contentRaw = $contentMatch[1];
$contentRaw = preg_replace('/<\s*br\s*\/?\s*>/i', "\n", $contentRaw) ?? $contentRaw;
$contentRaw = preg_replace('/<\/p>\s*<p[^>]*>/i', "\n\n", $contentRaw) ?? $contentRaw;
$content = $this->sanitizeText($contentRaw);
if ($content === '') {
return null;
}
$content = "Source: {$url}\n\n{$content}";
return [$title, Str::limit($content, 64000, '')];
}
private function sanitizeText(string $value): string
{
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$decoded = preg_replace('/\s+/', ' ', $decoded) ?? $decoded;
return trim($decoded);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\DTOs\ClassificationResultDTO;
use App\Models\AIDecision;
use App\Models\Article;
use App\Models\Ticket;
use App\Repositories\Contracts\ArticleRepositoryInterface;
class SemanticSearchService
{
public function __construct(
private readonly EmbeddingService $embeddingService,
private readonly ArticleRepositoryInterface $articleRepository,
private readonly AIClassifierService $classifierService,
) {}
public function findBestArticle(Ticket $ticket): array
{
$embedding = $ticket->embedding ?? $this->embeddingService->embed($ticket->message);
if ($ticket->embedding === null) {
$ticket->embedding = $embedding;
$ticket->save();
}
$candidates = $this->articleRepository->findSimilarByEmbedding($embedding, 5);
$classification = $this->classifierService->rank($ticket->message, $candidates);
$bestArticle = $classification->articleId ? Article::find($classification->articleId) : null;
AIDecision::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $bestArticle?->id,
'confidence' => $classification->confidence,
'explanation' => $classification->explanation,
'raw_response' => $classification->rawResponse,
]);
return [
'best_article' => $bestArticle,
'confidence' => $classification->confidence,
'explanation' => $classification->explanation,
'top_3_candidates' => collect($candidates)->take(3)->map(fn ($c) => $c->toArray())->values()->all(),
];
}
}