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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
/vendor
/node_modules
/storage/*.key
/storage/logs/*
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/testing/*
/storage/framework/views/*
/.git
/.idea
/.vscode
.env

29
.env.docker.example Normal file
View File

@@ -0,0 +1,29 @@
APP_NAME=TicketAssistant
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8090
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=ticket_assistant
DB_USERNAME=postgres
DB_PASSWORD=postgres
QUEUE_CONNECTION=database
CACHE_STORE=file
# External Ollama server in your network
OLLAMA_BASE_URL=http://192.168.1.50:11434
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_CHAT_MODEL=llama3
OLLAMA_TIMEOUT=30
EMBEDDING_DIMENSION=768
QUEUE_EMBEDDINGS=false
DB_SSLMODE=disable

111
README.md
View File

@@ -1,58 +1,85 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # TicketAssistant (Laravel 13 + pgvector + external Ollama)
<p align="center"> AI-powered customer support ticket system using semantic retrieval + LLM re-ranking.
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## Requirements
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - Docker Desktop
- Docker Compose v2
- Ollama running on a reachable network server
- [Simple, fast routing engine](https://laravel.com/docs/routing). ## Quick Start (Docker)
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
1. Create environment file
```bash ```bash
composer require laravel/boost --dev cp .env.docker.example .env
php artisan boost:install
``` ```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. 2. Set the external Ollama URL in `.env`
```env
OLLAMA_BASE_URL=http://<OLLAMA_SERVER_IP_OR_HOSTNAME>:11434
```
## Contributing 3. Build and start containers
```bash
docker compose up -d --build
```
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 4. Install PHP dependencies in app container
```bash
docker compose exec app composer install
```
## Code of Conduct 5. Generate app key and run database setup
```bash
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed
```
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). API base URL: `http://localhost:8090/api`
Admin dashboard: `http://localhost:8090/admin/dashboard`
## Security Vulnerabilities ## Admin Dashboard (Blade + Livewire)
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. - `/admin/dashboard`: KPI cards + recente tickets + recente AI decisions
- `/admin/articles`: artikel aanmaken + artikeloverzicht
- `/admin/tickets`: tickets met AI-classificatie-overzicht
## License Business logic is isolated in service classes:
- `App\Services\AdminDashboardService`
- `App\Services\AdminArticleService`
- `App\Services\AdminTicketService`
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). ## Services
- `web`: Nginx (public endpoint)
- `app`: PHP-FPM Laravel runtime
- `worker`: queue worker for async jobs
- `postgres`: PostgreSQL 17 + pgvector
## Notes
- No external AI APIs are used.
- Ollama runs outside Docker on your network.
- Set `QUEUE_EMBEDDINGS=true` for async article embedding generation.
## Helpdesk Scraper Command
Importeer artikelen vanaf InternetToday Helpdesk (categorieen + subcategorieen):
```bash
docker compose exec app php artisan helpdesk:import
```
Opties:
```bash
# test zonder schrijven
docker compose exec app php artisan helpdesk:import --dry-run
# verwerk alleen eerste N artikelen
docker compose exec app php artisan helpdesk:import --limit=25
# custom base-url
docker compose exec app php artisan helpdesk:import --base-url=https://www.internettoday.nl/helpdesk
```

36
app/Casts/VectorCast.php Normal file
View 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).']';
}
}

View 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;
}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class OllamaUnavailableException extends RuntimeException
{
}

View 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);
}
}

View 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);
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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();
}
}

View 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),
]);
}
}

View 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(),
]);
}
}

View 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
View 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
View 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);
}
}

View 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
View 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
View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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;
}

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(),
];
}
}

View File

@@ -7,9 +7,13 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withCommands([
__DIR__.'/../app/Console/Commands',
])
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// //
}) })

View File

@@ -1,7 +1,5 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
AppServiceProvider::class, App\Providers\AppServiceProvider::class,
]; ];

View File

@@ -1,23 +1,16 @@
{ {
"$schema": "https://getcomposer.org/schema.json", "name": "local/ticket-assistant",
"name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "AI-powered support ticket system with Laravel + pgvector + Ollama",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/tinker": "^3.0" "guzzlehttp/guzzle": "^7.9",
"livewire/livewire": "^4.2"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "phpunit/phpunit": "^11.0",
"laravel/pail": "^1.2.5", "fakerphp/faker": "^1.23"
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -26,61 +19,12 @@
"Database\\Seeders\\": "database/seeders/" "Database\\Seeders\\": "database/seeders/"
} }
}, },
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": { "scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
] ]
}, },
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true
} }

7824
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,184 +1,21 @@
<?php <?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [ return [
'default' => env('DB_CONNECTION', 'pgsql'),
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [ 'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [ 'pgsql' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'), 'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'root'), 'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''), 'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'), 'charset' => 'utf8',
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'schema' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSLMODE', 'prefer'),
], ],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
], ],
'migrations' => 'migrations',
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
]; ];

View File

@@ -1,40 +1,8 @@
<?php <?php
return [ return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'), 'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [ 'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [ 'database' => [
'driver' => 'database', 'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'), 'connection' => env('DB_QUEUE_CONNECTION'),
@@ -43,87 +11,13 @@ return [
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false, 'after_commit' => false,
], ],
'sync' => [
'beanstalkd' => [ 'driver' => 'sync',
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
], ],
], ],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [ 'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'), 'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs', 'table' => 'failed_jobs',
], ],
]; ];

View File

@@ -1,38 +1,14 @@
<?php <?php
return [ return [
'ollama' => [
/* 'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
|-------------------------------------------------------------------------- 'embed_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'),
| Third Party Services 'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
|-------------------------------------------------------------------------- 'timeout' => (int) env('OLLAMA_TIMEOUT', 30),
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
], ],
'embedding' => [
'resend' => [ 'dimension' => (int) env('EMBEDDING_DIMENSION', 768),
'key' => env('RESEND_API_KEY'), 'queue_embeddings' => filter_var(env('QUEUE_EMBEDDINGS', false), FILTER_VALIDATE_BOOL),
], ],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
]; ];

View File

@@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
}
public function down(): void
{
DB::statement('DROP EXTENSION IF EXISTS vector');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->timestamps();
});
$dimension = (int) config('services.embedding.dimension', 768);
DB::statement("ALTER TABLE articles ADD COLUMN embedding vector({$dimension})");
DB::statement('CREATE INDEX articles_embedding_cosine_idx ON articles USING ivfflat (embedding vector_cosine_ops)');
}
public function down(): void
{
Schema::dropIfExists('articles');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->text('message');
$table->timestamps();
});
$dimension = (int) config('services.embedding.dimension', 768);
DB::statement("ALTER TABLE tickets ADD COLUMN embedding vector({$dimension})");
DB::statement('CREATE INDEX tickets_embedding_cosine_idx ON tickets USING ivfflat (embedding vector_cosine_ops)');
}
public function down(): void
{
Schema::dropIfExists('tickets');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('embedding_cache', function (Blueprint $table) {
$table->id();
$table->string('text_hash', 64)->unique();
$table->longText('text');
$table->json('embedding');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('embedding_cache');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('ai_decisions', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('article_id')->nullable()->constrained()->nullOnDelete();
$table->float('confidence')->default(0);
$table->text('explanation')->nullable();
$table->json('raw_response')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ai_decisions');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('feedback', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('article_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('is_correct');
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedback');
}
};

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Seeders;
use App\Models\Article;
use Illuminate\Database\Seeder;
class ArticleSeeder extends Seeder
{
public function run(): void
{
$articles = [
['title' => 'Password Reset Instructions', 'content' => 'Use the Forgot Password link on login. A reset email arrives within 2 minutes. Check spam folder if not received.'],
['title' => 'Refund Policy', 'content' => 'Refund requests are accepted within 14 days for annual plans and 7 days for monthly plans.'],
['title' => 'Two-Factor Authentication Setup', 'content' => 'Enable 2FA from Account Security. Scan the QR code in your authenticator app and confirm with OTP.'],
['title' => 'Subscription Upgrade Guide', 'content' => 'Go to Billing, click Change Plan, choose Pro or Enterprise, and confirm immediate prorated billing.'],
['title' => 'Webhook Delivery Troubleshooting', 'content' => 'Verify endpoint HTTPS, 2xx response, and signature validation. Retry logs are available in Developer Settings.'],
];
foreach ($articles as $article) {
Article::query()->create($article);
}
}
}

View File

@@ -2,24 +2,14 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $this->call([
ArticleSeeder::class,
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]); ]);
} }
} }

72
docker-compose.yml Normal file
View File

@@ -0,0 +1,72 @@
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: ticketassistant-app
working_dir: /var/www
volumes:
- ./:/var/www
depends_on:
postgres:
condition: service_healthy
environment:
APP_ENV: local
APP_DEBUG: "true"
networks:
- ticketassistant
web:
image: nginx:1.27-alpine
container_name: ticketassistant-web
ports:
- "8090:80"
volumes:
- ./:/var/www
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- ticketassistant
worker:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: ticketassistant-worker
working_dir: /var/www
command: sh -c "php artisan queue:work --tries=3 --timeout=120"
volumes:
- ./:/var/www
depends_on:
postgres:
condition: service_healthy
networks:
- ticketassistant
postgres:
image: pgvector/pgvector:pg17
container_name: ticketassistant-postgres
environment:
POSTGRES_DB: ticket_assistant
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d ticket_assistant"]
interval: 5s
timeout: 5s
retries: 20
networks:
- ticketassistant
volumes:
pgdata:
networks:
ticketassistant:
driver: bridge

21
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass app:9000;
fastcgi_index index.php;
}
location ~ /\.ht {
deny all;
}
}

22
docker/php/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM php:8.3-fpm-alpine
WORKDIR /var/www
RUN apk add --no-cache \
bash \
curl \
git \
libpq \
libzip-dev \
oniguruma-dev \
postgresql-dev \
unzip \
zip \
icu-dev \
$PHPIZE_DEPS \
&& docker-php-ext-install pdo_pgsql mbstring zip intl \
&& rm -rf /var/cache/apk/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
CMD ["php-fpm", "-F"]

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Articles">
<livewire:admin.article-manager />
</x-layouts.admin>

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Dashboard">
<livewire:admin.dashboard-overview />
</x-layouts.admin>

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Tickets">
<livewire:admin.ticket-monitor />
</x-layouts.admin>

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Admin' }} - Ticket Assistant</title>
<script src="https://cdn.tailwindcss.com"></script>
@livewireStyles
</head>
<body class="bg-slate-100 text-slate-900">
<div class="min-h-screen">
<header class="bg-slate-900 text-white">
<div class="mx-auto max-w-7xl px-6 py-4 flex items-center justify-between">
<h1 class="text-lg font-semibold">Ticket Assistant Admin</h1>
<nav class="flex gap-3 text-sm">
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
{{ $slot }}
</main>
</div>
@livewireScripts
</body>
</html>

View File

@@ -0,0 +1,28 @@
<div class="space-y-6">
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Nieuw Artikel</h2>
@if (session('success'))
<div class="mb-3 text-green-700 bg-green-100 p-2 rounded">{{ session('success') }}</div>
@endif
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-40" placeholder="Content"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
</form>
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Artikelen</h2>
<div class="space-y-3">
@foreach($articles as $article)
<div class="border rounded p-3">
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}</div>
</div>
@endforeach
</div>
<div class="mt-4">{{ $articles->links() }}</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-4">
<div class="bg-white rounded-xl p-4 shadow">Articles<br><span class="text-2xl font-bold">{{ $stats['articles_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Tickets<br><span class="text-2xl font-bold">{{ $stats['tickets_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">AI Decisions<br><span class="text-2xl font-bold">{{ $stats['decisions_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Feedback Accuracy<br><span class="text-2xl font-bold">{{ isset($stats['feedback_accuracy']) ? $stats['feedback_accuracy'].'%' : 'N/A' }}</span></div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Recent Tickets</h2>
<ul class="space-y-2 text-sm">
@forelse($recentTickets as $ticket)
<li class="border-b pb-2">#{{ $ticket->id }} - {{ \Illuminate\Support\Str::limit($ticket->message, 100) }}</li>
@empty
<li>Geen tickets.</li>
@endforelse
</ul>
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Recent AI Decisions</h2>
<ul class="space-y-2 text-sm">
@forelse($recentDecisions as $decision)
<li class="border-b pb-2">
Ticket #{{ $decision->ticket_id }} -> Article #{{ $decision->article_id ?? 'N/A' }}
<span class="text-slate-500">({{ number_format($decision->confidence, 2) }})</span>
</li>
@empty
<li>Geen beslissingen.</li>
@endforelse
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Tickets + AI Decisions</h2>
<div class="space-y-3">
@foreach($tickets as $ticket)
<div class="border rounded p-3">
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
@php($decision = $ticket->decisions->first())
@if($decision)
<div class="text-sm">Article: #{{ $decision->article_id ?? 'N/A' }} | Confidence: {{ number_format($decision->confidence, 2) }}</div>
<div class="text-xs text-slate-500">{{ $decision->explanation }}</div>
@else
<div class="text-sm text-slate-500">Nog geen AI beslissing.</div>
@endif
</div>
@endforeach
</div>
<div class="mt-4">{{ $tickets->links() }}</div>
</div>

9
routes/api.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\TicketController;
use Illuminate\Support\Facades\Route;
Route::get('/articles', [ArticleController::class, 'index']);
Route::post('/articles', [ArticleController::class, 'store']);
Route::post('/tickets', [TicketController::class, 'store']);

View File

@@ -3,5 +3,11 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return redirect()->route('admin.dashboard');
});
Route::prefix('admin')->group(function () {
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
Route::view('/articles', 'admin.articles')->name('admin.articles');
Route::view('/tickets', 'admin.tickets')->name('admin.tickets');
}); });