Build Laravel 13 ticket assistant with Docker, Livewire admin, and helpdesk scraper command
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
29
.env.docker.example
Normal 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
111
README.md
@@ -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">
|
||||
<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>
|
||||
AI-powered customer support ticket system using semantic retrieval + LLM re-ranking.
|
||||
|
||||
## 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).
|
||||
- [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:
|
||||
## Quick Start (Docker)
|
||||
|
||||
1. Create environment file
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
cp .env.docker.example .env
|
||||
```
|
||||
|
||||
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
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withCommands([
|
||||
__DIR__.'/../app/Console/Commands',
|
||||
])
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
112
composer.json
112
composer.json
@@ -1,86 +1,30 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@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",
|
||||
"prefer-stable": true
|
||||
"name": "local/ticket-assistant",
|
||||
"type": "project",
|
||||
"description": "AI-powered support ticket system with Laravel + pgvector + Ollama",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.0",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"livewire/livewire": "^4.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"fakerphp/faker": "^1.23"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
||||
7824
composer.lock
generated
Normal file
7824
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,184 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'pgsql'),
|
||||
'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' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'schema' => 'public',
|
||||
'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'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
'migrations' => 'migrations',
|
||||
];
|
||||
|
||||
110
config/queue.php
110
config/queue.php
@@ -1,40 +1,8 @@
|
||||
<?php
|
||||
|
||||
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'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
@@ -43,87 +11,13 @@ return [
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'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,
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'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' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,38 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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'),
|
||||
'ollama' => [
|
||||
'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
|
||||
'embed_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'),
|
||||
'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
|
||||
'timeout' => (int) env('OLLAMA_TIMEOUT', 30),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
'embedding' => [
|
||||
'dimension' => (int) env('EMBEDDING_DIMENSION', 768),
|
||||
'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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
24
database/seeders/ArticleSeeder.php
Normal file
24
database/seeders/ArticleSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,14 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
$this->call([
|
||||
ArticleSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
docker-compose.yml
Normal file
72
docker-compose.yml
Normal 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
21
docker/nginx/default.conf
Normal 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
22
docker/php/Dockerfile
Normal 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"]
|
||||
3
resources/views/admin/articles.blade.php
Normal file
3
resources/views/admin/articles.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Articles">
|
||||
<livewire:admin.article-manager />
|
||||
</x-layouts.admin>
|
||||
3
resources/views/admin/dashboard.blade.php
Normal file
3
resources/views/admin/dashboard.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Dashboard">
|
||||
<livewire:admin.dashboard-overview />
|
||||
</x-layouts.admin>
|
||||
3
resources/views/admin/tickets.blade.php
Normal file
3
resources/views/admin/tickets.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Tickets">
|
||||
<livewire:admin.ticket-monitor />
|
||||
</x-layouts.admin>
|
||||
29
resources/views/components/layouts/admin.blade.php
Normal file
29
resources/views/components/layouts/admin.blade.php
Normal 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>
|
||||
28
resources/views/livewire/admin/article-manager.blade.php
Normal file
28
resources/views/livewire/admin/article-manager.blade.php
Normal 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>
|
||||
35
resources/views/livewire/admin/dashboard-overview.blade.php
Normal file
35
resources/views/livewire/admin/dashboard-overview.blade.php
Normal 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>
|
||||
19
resources/views/livewire/admin/ticket-monitor.blade.php
Normal file
19
resources/views/livewire/admin/ticket-monitor.blade.php
Normal 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
9
routes/api.php
Normal 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']);
|
||||
@@ -3,5 +3,11 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user