Build Laravel 13 ticket assistant with Docker, Livewire admin, and helpdesk scraper command
This commit is contained in:
36
app/Casts/VectorCast.php
Normal file
36
app/Casts/VectorCast.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
|
||||
class VectorCast implements CastsAttributes
|
||||
{
|
||||
public function get($model, string $key, $value, array $attributes): ?array
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$trimmed = trim((string) $value, '[]');
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static fn ($item) => (float) $item, explode(',', $trimmed));
|
||||
}
|
||||
|
||||
public function set($model, string $key, $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vector = array_map(static fn ($item) => (float) $item, $value);
|
||||
return '['.implode(',', $vector).']';
|
||||
}
|
||||
}
|
||||
44
app/Console/Commands/ImportHelpdeskArticlesCommand.php
Normal file
44
app/Console/Commands/ImportHelpdeskArticlesCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\HelpdeskImportService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ImportHelpdeskArticlesCommand extends Command
|
||||
{
|
||||
protected $signature = 'helpdesk:import
|
||||
{--base-url=https://www.internettoday.nl/helpdesk : Base helpdesk URL}
|
||||
{--limit= : Max number of article URLs to process}
|
||||
{--dry-run : Only detect and parse, do not write to database}';
|
||||
|
||||
protected $description = 'Scrape InternetToday helpdesk categories/subcategories/articles and import into articles table.';
|
||||
|
||||
public function handle(HelpdeskImportService $service): int
|
||||
{
|
||||
$limitOption = $this->option('limit');
|
||||
$limit = is_numeric($limitOption) ? (int) $limitOption : null;
|
||||
|
||||
$result = $service->import(
|
||||
(string) $this->option('base-url'),
|
||||
(bool) $this->option('dry-run'),
|
||||
$limit
|
||||
);
|
||||
|
||||
$this->info('Helpdesk import finished.');
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Categories', $result['categories']],
|
||||
['Sections', $result['sections']],
|
||||
['Article URLs', $result['article_urls']],
|
||||
['Imported', $result['imported']],
|
||||
['Updated', $result['updated']],
|
||||
['Skipped', $result['skipped']],
|
||||
['Dry Run', $result['dry_run'] ? 'yes' : 'no'],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/DTOs/ArticleCandidateDTO.php
Normal file
35
app/DTOs/ArticleCandidateDTO.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
use App\Models\Article;
|
||||
|
||||
class ArticleCandidateDTO
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $articleId,
|
||||
public readonly string $title,
|
||||
public readonly string $content,
|
||||
public readonly float $distance
|
||||
) {}
|
||||
|
||||
public static function fromArticle(Article $article, float $distance): self
|
||||
{
|
||||
return new self(
|
||||
articleId: $article->id,
|
||||
title: $article->title,
|
||||
content: $article->content,
|
||||
distance: $distance
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'article_id' => $this->articleId,
|
||||
'title' => $this->title,
|
||||
'content' => $this->content,
|
||||
'distance' => $this->distance,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/DTOs/ClassificationResultDTO.php
Normal file
23
app/DTOs/ClassificationResultDTO.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
class ClassificationResultDTO
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $articleId,
|
||||
public readonly float $confidence,
|
||||
public readonly string $explanation,
|
||||
public readonly array $rawResponse = []
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'article_id' => $this->articleId,
|
||||
'confidence' => $this->confidence,
|
||||
'explanation' => $this->explanation,
|
||||
'raw_response' => $this->rawResponse,
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Exceptions/OllamaUnavailableException.php
Normal file
9
app/Exceptions/OllamaUnavailableException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OllamaUnavailableException extends RuntimeException
|
||||
{
|
||||
}
|
||||
28
app/Http/Controllers/Api/ArticleController.php
Normal file
28
app/Http/Controllers/Api/ArticleController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreArticleRequest;
|
||||
use App\Models\Article;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => Article::query()->latest()->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreArticleRequest $request): JsonResponse
|
||||
{
|
||||
$article = Article::query()->create($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Article created',
|
||||
'data' => $article,
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Api/TicketController.php
Normal file
52
app/Http/Controllers/Api/TicketController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\OllamaUnavailableException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTicketRequest;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\EmbeddingService;
|
||||
use App\Services\SemanticSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EmbeddingService $embeddingService,
|
||||
private readonly SemanticSearchService $semanticSearchService,
|
||||
) {}
|
||||
|
||||
public function store(StoreTicketRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$embedding = $this->embeddingService->embed($request->string('message')->toString());
|
||||
} catch (OllamaUnavailableException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Ollama is unavailable. Could not generate embedding.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'message' => $request->string('message')->toString(),
|
||||
'embedding' => $embedding,
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->semanticSearchService->findBestArticle($ticket);
|
||||
} catch (OllamaUnavailableException $e) {
|
||||
return response()->json([
|
||||
'message' => 'Ticket saved, but Ollama ranking is unavailable.',
|
||||
'ticket_id' => $ticket->id,
|
||||
], 202);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ticket_id' => $ticket->id,
|
||||
'best_article' => $result['best_article'],
|
||||
'confidence' => $result['confidence'],
|
||||
'explanation' => $result['explanation'],
|
||||
'top_3_candidates' => $result['top_3_candidates'],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/StoreArticleRequest.php
Normal file
21
app/Http/Requests/StoreArticleRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreArticleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'max:12000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/StoreTicketRequest.php
Normal file
20
app/Http/Requests/StoreTicketRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreTicketRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'message' => ['required', 'string', 'min:5', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Jobs/GenerateArticleEmbeddingJob.php
Normal file
31
app/Jobs/GenerateArticleEmbeddingJob.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Services\EmbeddingService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GenerateArticleEmbeddingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly int $articleId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(EmbeddingService $embeddingService): void
|
||||
{
|
||||
$article = Article::find($this->articleId);
|
||||
if ($article === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$article->embedding = $embeddingService->embed($article->title."\n".$article->content);
|
||||
$article->save();
|
||||
}
|
||||
}
|
||||
36
app/Livewire/Admin/ArticleManager.php
Normal file
36
app/Livewire/Admin/ArticleManager.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AdminArticleService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class ArticleManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $title = '';
|
||||
public string $content = '';
|
||||
|
||||
public function save(AdminArticleService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'max:12000'],
|
||||
]);
|
||||
|
||||
$service->create($this->title, $this->content);
|
||||
|
||||
$this->reset(['title', 'content']);
|
||||
$this->dispatch('article-saved');
|
||||
session()->flash('success', 'Article opgeslagen en embedding wordt automatisch verwerkt.');
|
||||
}
|
||||
|
||||
public function render(AdminArticleService $service)
|
||||
{
|
||||
return view('livewire.admin.article-manager', [
|
||||
'articles' => $service->paginate(10),
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Livewire/Admin/DashboardOverview.php
Normal file
24
app/Livewire/Admin/DashboardOverview.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AdminDashboardService;
|
||||
use Livewire\Component;
|
||||
|
||||
class DashboardOverview extends Component
|
||||
{
|
||||
public array $stats = [];
|
||||
|
||||
public function mount(AdminDashboardService $service): void
|
||||
{
|
||||
$this->stats = $service->stats();
|
||||
}
|
||||
|
||||
public function render(AdminDashboardService $service)
|
||||
{
|
||||
return view('livewire.admin.dashboard-overview', [
|
||||
'recentTickets' => $service->recentTickets(),
|
||||
'recentDecisions' => $service->recentDecisions(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Livewire/Admin/TicketMonitor.php
Normal file
19
app/Livewire/Admin/TicketMonitor.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AdminTicketService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class TicketMonitor extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public function render(AdminTicketService $service)
|
||||
{
|
||||
return view('livewire.admin.ticket-monitor', [
|
||||
'tickets' => $service->paginateWithDecision(10),
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Models/AIDecision.php
Normal file
27
app/Models/AIDecision.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AIDecision extends Model
|
||||
{
|
||||
protected $table = 'ai_decisions';
|
||||
|
||||
protected $fillable = ['ticket_id', 'article_id', 'confidence', 'explanation', 'raw_response'];
|
||||
|
||||
protected $casts = [
|
||||
'raw_response' => 'array',
|
||||
];
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function article(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Article::class);
|
||||
}
|
||||
}
|
||||
21
app/Models/Article.php
Normal file
21
app/Models/Article.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\VectorCast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Article extends Model
|
||||
{
|
||||
protected $fillable = ['title', 'content', 'embedding'];
|
||||
|
||||
protected $casts = [
|
||||
'embedding' => VectorCast::class,
|
||||
];
|
||||
|
||||
public function decisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AIDecision::class);
|
||||
}
|
||||
}
|
||||
16
app/Models/EmbeddingCache.php
Normal file
16
app/Models/EmbeddingCache.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EmbeddingCache extends Model
|
||||
{
|
||||
protected $table = 'embedding_cache';
|
||||
|
||||
protected $fillable = ['text_hash', 'text', 'embedding'];
|
||||
|
||||
protected $casts = [
|
||||
'embedding' => 'array',
|
||||
];
|
||||
}
|
||||
12
app/Models/Feedback.php
Normal file
12
app/Models/Feedback.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Feedback extends Model
|
||||
{
|
||||
protected $table = 'feedback';
|
||||
|
||||
protected $fillable = ['ticket_id', 'article_id', 'is_correct', 'notes'];
|
||||
}
|
||||
26
app/Models/Ticket.php
Normal file
26
app/Models/Ticket.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\VectorCast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected $fillable = ['message', 'embedding'];
|
||||
|
||||
protected $casts = [
|
||||
'embedding' => VectorCast::class,
|
||||
];
|
||||
|
||||
public function decisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AIDecision::class);
|
||||
}
|
||||
|
||||
public function feedback(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
27
app/Observers/ArticleObserver.php
Normal file
27
app/Observers/ArticleObserver.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Jobs\GenerateArticleEmbeddingJob;
|
||||
use App\Models\Article;
|
||||
use App\Services\EmbeddingService;
|
||||
|
||||
class ArticleObserver
|
||||
{
|
||||
public function saved(Article $article): void
|
||||
{
|
||||
if (! $article->wasChanged(['title', 'content']) && $article->embedding !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((bool) config('services.embedding.queue_embeddings')) {
|
||||
GenerateArticleEmbeddingJob::dispatch($article->id);
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(EmbeddingService::class);
|
||||
$article->embedding = $service->embed($article->title."\n".$article->content);
|
||||
|
||||
$article->saveQuietly();
|
||||
}
|
||||
}
|
||||
28
app/Repositories/ArticleRepository.php
Normal file
28
app/Repositories/ArticleRepository.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\DTOs\ArticleCandidateDTO;
|
||||
use App\Models\Article;
|
||||
use App\Repositories\Contracts\ArticleRepositoryInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ArticleRepository implements ArticleRepositoryInterface
|
||||
{
|
||||
public function findSimilarByEmbedding(array $embedding, int $limit = 5): array
|
||||
{
|
||||
$vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']';
|
||||
|
||||
$rows = Article::query()
|
||||
->select('articles.*')
|
||||
->selectRaw('embedding <=> ?::vector as distance', [$vector])
|
||||
->whereNotNull('embedding')
|
||||
->orderByRaw('embedding <=> ?::vector', [$vector])
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $rows
|
||||
->map(fn (Article $article) => ArticleCandidateDTO::fromArticle($article, (float) $article->distance))
|
||||
->all();
|
||||
}
|
||||
}
|
||||
11
app/Repositories/Contracts/ArticleRepositoryInterface.php
Normal file
11
app/Repositories/Contracts/ArticleRepositoryInterface.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories\Contracts;
|
||||
|
||||
use App\DTOs\ArticleCandidateDTO;
|
||||
|
||||
interface ArticleRepositoryInterface
|
||||
{
|
||||
/** @return array<ArticleCandidateDTO> */
|
||||
public function findSimilarByEmbedding(array $embedding, int $limit = 5): array;
|
||||
}
|
||||
83
app/Services/AIClassifierService.php
Normal file
83
app/Services/AIClassifierService.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/Services/AdminArticleService.php
Normal file
21
app/Services/AdminArticleService.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Services/AdminDashboardService.php
Normal file
42
app/Services/AdminDashboardService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
app/Services/AdminTicketService.php
Normal file
16
app/Services/AdminTicketService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
app/Services/EmbeddingService.php
Normal file
47
app/Services/EmbeddingService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
170
app/Services/HelpdeskImportService.php
Normal file
170
app/Services/HelpdeskImportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
app/Services/SemanticSearchService.php
Normal file
47
app/Services/SemanticSearchService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user