Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 106 additions & 50 deletions app/Http/Controllers/Tickets/Crud.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ public function manage(Request $request): Response
return $this->renderTicketList($request, 'tickets/manage');
}

public function aiFollowUp(Request $request, Ticket $ticket): RedirectResponse
{
$this->authorize('useAiSuggestions', $ticket);

$validated = $request->validate([
'feedback' => 'required|string|max:1000',
]);

$latestSuggestion = $ticket->aiSuggestions()->latest()->first();

try {
$payload = [
'ticket_id' => $ticket->id,
'title' => $ticket->title,
'description' => $ticket->description,
'status' => 'pending_analysis',
'previous_suggestion' => $latestSuggestion ? json_encode($latestSuggestion->generated_content) : null,
'user_feedback' => $validated['feedback']
];

// Direct Redis push (matching Listener logic)
// Using 'ai_worker' connection as per DispatchTicketToAiQueue listener
\Illuminate\Support\Facades\Redis::connection('ai_worker')->rpush('ticket_processing_queue', json_encode($payload));

// Optional: You might want to create a placeholder AiSuggestion with status 'generating' if you were tracking status in DB,
// but for now we just push to queue.

return back()->with('flash.created', 'AI refinement requested.');

} catch (\Exception $e) {
return back()->with('flash.error', 'Failed to request AI refinement: ' . $e->getMessage());
}
}

private function applyUserVisibilityFilter(Builder $query, User $user, bool $onlyMyTickets = false): Builder
{
if ($user->hasRole('admin')) {
Expand Down Expand Up @@ -110,7 +144,7 @@ private function renderTicketList(Request $request, string $view): Response

$open = (clone $statsQuery)
->where(function (Builder $q) {
$q->whereHas('status', fn (Builder $subQ) => $subQ->where('is_closed', false))
$q->whereHas('status', fn(Builder $subQ) => $subQ->where('is_closed', false))
->orWhereNull('status_id');
})
->count();
Expand All @@ -120,21 +154,21 @@ private function renderTicketList(Request $request, string $view): Response
->count();

$resolved = (clone $statsQuery)
->whereHas('status', fn (Builder $q) => $q->where('is_closed', true))
->whereHas('status', fn(Builder $q) => $q->where('is_closed', true))
->count();

$driver = config('database.default');
$connection = config("database.connections.$driver.driver");

if ($connection === 'sqlite') {
$avgResolutionDays = (clone $statsQuery)
->whereHas('status', fn (Builder $q) => $q->where('is_closed', true))
->whereHas('status', fn(Builder $q) => $q->where('is_closed', true))
->whereNotNull('updated_at')
->selectRaw('AVG(JULIANDAY(updated_at) - JULIANDAY(created_at)) as avg_days')
->value('avg_days') ?? 0;
} else {
$avgResolutionDays = (clone $statsQuery)
->whereHas('status', fn (Builder $q) => $q->where('is_closed', true))
->whereHas('status', fn(Builder $q) => $q->where('is_closed', true))
->whereNotNull('updated_at')
->selectRaw('AVG(TIMESTAMPDIFF(DAY, created_at, updated_at)) as avg_days')
->value('avg_days') ?? 0;
Expand Down Expand Up @@ -221,54 +255,89 @@ public function create(): Response
]);
}

public function show(Ticket $ticket): Response
public function show(Request $request, Ticket $ticket): Response
{
$ticket->load([
'user.avatar', 'priority', 'status', 'category', 'asset',
'assignees.user.avatar', 'comments.user.avatar', 'comments.attachments',
'logs.user.avatar', 'schedules.user.avatar', 'attachments',
'user.avatar',
'priority',
'status',
'category',
'asset',
'assignees.user.avatar',
'comments.user.avatar',
'comments.attachments',
'logs.user.avatar',
'schedules.user.avatar',
'attachments'
]);

if ($request->user()->can('use ai suggestions tickets')) {
$ticket->load(['aiSuggestions' => fn($q) => $q->latest()]);
}

return Inertia::render('tickets/show', [
'ticket' => $ticket,
'events' => $this->getTicketEvents($ticket),
'solvers' => User::permission('be assigned tickets')->with('avatar')->get()->map(fn($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->avatar,
])->toArray(),
'similar_tickets' => $this->getSimilarTickets($ticket),
]);
}

private function getSimilarTickets(Ticket $ticket): array
{
$searchContext = implode(' ', array_filter([
$ticket->title,
$ticket->description,
$ticket->category?->title,
$ticket->asset?->title,
]));

$similarTickets = [];

try {
$results = $this->vectorSearch->search([
'query' => $searchContext,
'limit' => 6,
]);

if (!empty($results) && !isset($results['error'])) {
$filteredResults = collect($results)
->filter(fn($r) => $r['ticket_id'] != $ticket->id)
->take(6);

if ($filteredResults->isNotEmpty()) {
$ticketsInfo = Ticket::whereIn('id', $filteredResults->pluck('ticket_id'))
->get(['id', 'title'])
->keyBy('id');

$similarTickets = $filteredResults->map(function ($result) use ($ticketsInfo) {
$info = $ticketsInfo->get($result['ticket_id']);
if (!$info) return null;

return [
'id' => $info->id,
'title' => $info->title,
'similarity' => round(max(0, min(1, 1 - ($result['score'] / 2))) * 100),
];
})->filter()->values()->toArray();
}
if (empty($results) || isset($results['error'])) {
return [];
}

$filteredResults = collect($results)
->filter(fn($r) => $r['ticket_id'] != $ticket->id)
->take(6);

if ($filteredResults->isEmpty()) {
return [];
}

$ticketsInfo = Ticket::whereIn('id', $filteredResults->pluck('ticket_id'))
->get(['id', 'title'])
->keyBy('id');

return $filteredResults->map(function ($result) use ($ticketsInfo) {
$info = $ticketsInfo->get($result['ticket_id']);
if (!$info)
return null;

return [
'id' => $info->id,
'title' => $info->title,
'similarity' => round(max(0, min(1, 1 - ($result['score'] / 2))) * 100),
];
})->filter()->values()->toArray();

} catch (Throwable) {
return [];
}
}

private function getTicketEvents(Ticket $ticket): array
{
$schedules = TicketSchedule::with(['user.avatar', 'ticket.priority', 'ticket.status', 'ticket.category'])
->where('ticket_id', $ticket->id)
->get()
Expand All @@ -295,22 +364,9 @@ public function show(Ticket $ticket): Response
->get()
->map(fn($entry) => $entry->toCalendarEvent())
->filter(fn($event) => !empty($event))
->values()
->toArray();
->values();

$events = $schedules->concat($entries)->values()->all();

return Inertia::render('tickets/show', [
'ticket' => $ticket,
'events' => $events,
'solvers' => User::permission('be assigned tickets')->with('avatar')->get()->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->avatar,
])->toArray(),
'similar_tickets' => $similarTickets,
]);
return $schedules->concat($entries)->values()->all();
}

/**
Expand Down Expand Up @@ -540,7 +596,7 @@ public function archived(Request $request): Response
'assignees.user.avatar'
])->whereNotNull('archived_at');

if (! $user->can('view all archived tickets')) {
if (!$user->can('view all archived tickets')) {
$query = $this->applyUserVisibilityFilter($query, $user, true);
}

Expand All @@ -550,7 +606,7 @@ public function archived(Request $request): Response

$statsQuery = Ticket::whereNotNull('archived_at');

if (! $user->can('view all archived tickets')) {
if (!$user->can('view all archived tickets')) {
$statsQuery = $this->applyUserVisibilityFilter($statsQuery, $user, true);
}

Expand All @@ -561,10 +617,10 @@ public function archived(Request $request): Response
->whereNotNull('status_id')
->groupBy('status_id')
->get()
->mapWithKeys(fn ($item) => [$item->status_id => (int) $item->count]);
->mapWithKeys(fn($item) => [$item->status_id => (int) $item->count]);

$resolved = (clone $statsQuery)
->whereHas('status', fn (Builder $q) => $q->where('is_closed', true))
->whereHas('status', fn(Builder $q) => $q->where('is_closed', true))
->count();

$driver = config('database.default');
Expand Down
33 changes: 33 additions & 0 deletions app/Models/AiFeedback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class AiFeedback extends Model
{
use HasFactory;

protected $table = 'ai_feedbacks';

protected $fillable = [
'suggestion_id',
'user_id',
'action_type',
'final_content',
'rejection_reason',
'rejection_comment',
];

public function suggestion(): BelongsTo
{
return $this->belongsTo(AiSuggestion::class, 'suggestion_id');
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
39 changes: 39 additions & 0 deletions app/Models/AiSuggestion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;

class AiSuggestion extends Model
{
use HasFactory;

protected $fillable = [
'ticket_id',
'model_config_snapshot',
'prompt_hash',
'generated_content',
'retrieved_chunks',
'confidence_score',
'processing_time_ms',
];

protected $casts = [
'model_config_snapshot' => 'array',
'generated_content' => 'array',
'retrieved_chunks' => 'array',
];

public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}

public function feedback(): HasOne
{
return $this->hasOne(AiFeedback::class, 'suggestion_id');
}
}
10 changes: 9 additions & 1 deletion app/Models/Ticket.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\SoftDeletes;

class Ticket extends Model
class Ticket extends Model
{
use HasFactory, SoftDeletes;

Expand Down Expand Up @@ -143,6 +143,14 @@ public function author(): BelongsTo
return $this->belongsTo(User::class, 'author_id');
}

/**
* @return HasMany
*/
public function aiSuggestions(): HasMany
{
return $this->hasMany(AiSuggestion::class);
}

/**
* Scope to filter archived tickets
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Policies/Ticket.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,12 @@ public function unarchive(User $user, ModelsTicket $ticket): bool
return false;
}

/**
* Determine whether the user can use AI suggestions.
*/
public function useAiSuggestions(User $user): bool
{
return $user->can('use ai suggestions tickets');
}

}
1 change: 1 addition & 0 deletions backend-ai/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ redis==5.0.1
requests
jinja2
openai
pymysql
Loading