[FEAT] Sistema Segnalazioni & Risoluzione AI (feedback)
Adattato da alltax.it — il sistema più maturo testato con utenti reali.
Backend:
- FeedbackController: 6 endpoint (submit, mine, list, show, update, resolve)
- FeedbackService: createReport + classifyWithAI + broadcastResolution
- AIService::classifyFeedback() — 10s timeout, 500 token, JSON puro
- EmailService::sendFeedbackResolved() — broadcast email org
- DB migration 014: tabella feedback_reports
Frontend:
- feedback.js: FAB rosso #EF4444, modal 2 fasi (form → AI → password gate)
- Tab "Le mie segnalazioni" con badge status
- Auto-init su tutte le pagine autenticate (common.js::checkAuth)
- api.js: 6 metodi client; style.css: stili completi
Worker:
- scripts/feedback-worker.php: cron ogni 30 min
→ docker exec nis2-agile-devenv + Claude Code CLI
→ risoluzione autonoma con POST /api/feedback/{id}/resolve
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3b51b5bd95
commit
1382530189
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile — Feedback Worker
|
||||
*
|
||||
* Worker cron per la risoluzione autonoma dei ticket di segnalazione.
|
||||
* Adattato da alltax.it/docs/sistema-segnalazioni-standard.html
|
||||
*
|
||||
* Ciclo (ogni 30 min):
|
||||
* 1. Acquisisce lock — previene run paralleli
|
||||
* 2. Recupera ticket status='in_lavorazione'
|
||||
* 3. Per ciascuno: invoca Claude Code CLI nel container devenv
|
||||
* 4. Se exit_code=0: marca ticket come risolto + broadcast email
|
||||
* 5. Logga risultati in FEEDBACK_WORKER_LOG
|
||||
*
|
||||
* Crontab (root):
|
||||
* */30 * * * * root /usr/bin/php8.4 /var/www/nis2-agile/scripts/feedback-worker.php
|
||||
*
|
||||
* Variabili .env necessarie:
|
||||
* FEEDBACK_RESOLVE_PASSWORD=... (password per POST /api/feedback/{id}/resolve)
|
||||
* FEEDBACK_WORKER_ADMIN_EMAIL=...
|
||||
* FEEDBACK_WORKER_ADMIN_PASS=...
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Carica ambiente
|
||||
define('BASE_PATH', dirname(__DIR__));
|
||||
define('APP_PATH', BASE_PATH . '/application');
|
||||
define('PUBLIC_PATH', BASE_PATH . '/public');
|
||||
define('UPLOAD_PATH', PUBLIC_PATH . '/uploads');
|
||||
define('DATA_PATH', APP_PATH . '/data');
|
||||
|
||||
require_once APP_PATH . '/config/env.php';
|
||||
require_once APP_PATH . '/config/config.php';
|
||||
require_once APP_PATH . '/config/database.php';
|
||||
|
||||
// ── Config worker ─────────────────────────────────────────────────────────
|
||||
|
||||
const LOCK_FILE = '/tmp/nis2-feedback-worker.lock';
|
||||
const CLAUDE_TIMEOUT = 1500; // secondi (25 min, < cron interval 30 min)
|
||||
const DEVENV_CONTAINER = 'nis2-agile-devenv';
|
||||
const APP_URL_INTERNAL = 'http://localhost:8080'; // URL interno per chiamate PHP curl
|
||||
|
||||
$logFile = defined('FEEDBACK_WORKER_LOG') ? FEEDBACK_WORKER_LOG : '/tmp/nis2-feedback-worker.log';
|
||||
|
||||
// ── Lock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
$lockAge = time() - filemtime(LOCK_FILE);
|
||||
if ($lockAge < 1800) {
|
||||
_log($logFile, 'Worker già in esecuzione (lock attivo, età: ' . $lockAge . 's). Uscita.');
|
||||
exit(0);
|
||||
}
|
||||
_log($logFile, 'Lock obsoleto trovato (' . $lockAge . 's), rimuovo e procedo.');
|
||||
unlink(LOCK_FILE);
|
||||
}
|
||||
|
||||
file_put_contents(LOCK_FILE, posix_getpid());
|
||||
|
||||
register_shutdown_function(function () {
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
unlink(LOCK_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_log($logFile, '=== Feedback Worker avviato ===');
|
||||
|
||||
try {
|
||||
$tickets = Database::fetchAll(
|
||||
"SELECT id, tipo, descrizione, ai_suggerimento, ai_risposta, organization_id
|
||||
FROM feedback_reports
|
||||
WHERE status = 'in_lavorazione'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 10"
|
||||
);
|
||||
|
||||
if (empty($tickets)) {
|
||||
_log($logFile, 'Nessun ticket in lavorazione. Worker terminato.');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
_log($logFile, count($tickets) . ' ticket da processare.');
|
||||
|
||||
$resolvePassword = defined('FEEDBACK_RESOLVE_PASSWORD') ? FEEDBACK_RESOLVE_PASSWORD : '';
|
||||
if (empty($resolvePassword)) {
|
||||
_log($logFile, 'FEEDBACK_RESOLVE_PASSWORD non configurata. Worker non può risolvere ticket.');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
_processTicket($ticket, $resolvePassword, $logFile);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
_log($logFile, 'ERRORE FATALE: ' . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
_log($logFile, '=== Worker completato ===');
|
||||
exit(0);
|
||||
|
||||
// ── Funzioni ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _processTicket(array $ticket, string $password, string $logFile): void
|
||||
{
|
||||
$id = $ticket['id'];
|
||||
_log($logFile, "Processing ticket #{$id} [{$ticket['tipo']}]: " . substr($ticket['descrizione'], 0, 80) . '…');
|
||||
|
||||
// Costruisci prompt per Claude Code
|
||||
$prompt = _buildPrompt($ticket);
|
||||
|
||||
// Scrivi prompt su file temporaneo
|
||||
$promptFile = "/tmp/nis2-feedback-prompt-{$id}.txt";
|
||||
file_put_contents($promptFile, $prompt);
|
||||
|
||||
// Esegui Claude Code nel container devenv
|
||||
$dockerCmd = sprintf(
|
||||
'docker exec -u developer %s bash -c %s',
|
||||
escapeshellarg(DEVENV_CONTAINER),
|
||||
escapeshellarg(
|
||||
'cd /projects/nis2-agile && ' .
|
||||
'timeout ' . CLAUDE_TIMEOUT . ' ' .
|
||||
'claude --dangerously-skip-permissions --output-format stream-json ' .
|
||||
'-p "$(cat ' . $promptFile . ')" 2>&1'
|
||||
)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($dockerCmd, $output, $exitCode);
|
||||
|
||||
@unlink($promptFile);
|
||||
|
||||
$outputText = implode("\n", $output);
|
||||
_log($logFile, "Ticket #{$id} — Claude exit_code={$exitCode}, output=" . substr($outputText, 0, 200));
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
_log($logFile, "Ticket #{$id} — risoluzione fallita (exit {$exitCode}).");
|
||||
// Rimane in in_lavorazione per il prossimo ciclo
|
||||
return;
|
||||
}
|
||||
|
||||
// Chiama API interna per marcare come risolto
|
||||
$resolved = _resolveViaApi($id, $password, $logFile);
|
||||
|
||||
if ($resolved) {
|
||||
_log($logFile, "Ticket #{$id} — risolto e broadcast inviato.");
|
||||
}
|
||||
}
|
||||
|
||||
function _buildPrompt(array $ticket): string
|
||||
{
|
||||
$suggerimento = $ticket['ai_suggerimento'] ?? 'Nessun suggerimento AI disponibile.';
|
||||
|
||||
return <<<PROMPT
|
||||
Sei un developer senior che lavora su NIS2 Agile, una piattaforma PHP 8.4 per compliance NIS2.
|
||||
|
||||
## Ticket da risolvere
|
||||
ID: {$ticket['id']}
|
||||
Tipo: {$ticket['tipo']}
|
||||
Descrizione: {$ticket['descrizione']}
|
||||
|
||||
## Suggerimento AI (classificazione automatica)
|
||||
{$suggerimento}
|
||||
|
||||
## Istruzioni
|
||||
1. Analizza il problema descritto
|
||||
2. Identifica i file rilevanti nel progetto (/projects/nis2-agile/)
|
||||
3. Applica la correzione minimale necessaria
|
||||
4. Non modificare test o documentazione
|
||||
5. Assicurati che il codice PHP sia valido (sintassi corretta)
|
||||
6. Se il problema non è risolvibile in modo sicuro, esci con exit code 1
|
||||
|
||||
Risolvi il problema e poi esci. Non avviare server, non fare git commit.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
function _resolveViaApi(int $reportId, string $password, string $logFile): bool
|
||||
{
|
||||
$url = APP_URL_INTERNAL . "/api/feedback/{$reportId}/resolve";
|
||||
$payload = json_encode(['password' => $password]);
|
||||
|
||||
// Ottieni un token JWT admin per la chiamata interna
|
||||
$token = _getAdminToken($logFile);
|
||||
if (!$token) {
|
||||
_log($logFile, "Ticket #{$reportId} — impossibile ottenere token admin per risoluzione.");
|
||||
return false;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $token,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError || $httpCode < 200 || $httpCode >= 300) {
|
||||
_log($logFile, "Ticket #{$reportId} — API resolve fallita [{$httpCode}]: {$curlError}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return $data['success'] ?? false;
|
||||
}
|
||||
|
||||
function _getAdminToken(string $logFile): ?string
|
||||
{
|
||||
static $cachedToken = null;
|
||||
|
||||
if ($cachedToken !== null) return $cachedToken;
|
||||
|
||||
$adminEmail = Env::get('FEEDBACK_WORKER_ADMIN_EMAIL', '');
|
||||
$adminPass = Env::get('FEEDBACK_WORKER_ADMIN_PASS', '');
|
||||
|
||||
if (!$adminEmail || !$adminPass) {
|
||||
_log($logFile, 'FEEDBACK_WORKER_ADMIN_EMAIL / FEEDBACK_WORKER_ADMIN_PASS non configurate.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init(APP_URL_INTERNAL . '/api/auth/login');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['email' => $adminEmail, 'password' => $adminPass]),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
_log($logFile, "Login admin fallito [{$httpCode}].");
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$cachedToken = $data['data']['access_token'] ?? null;
|
||||
|
||||
return $cachedToken;
|
||||
}
|
||||
|
||||
function _log(string $logFile, string $message): void
|
||||
{
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . PHP_EOL;
|
||||
$dir = dirname($logFile);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
echo $line;
|
||||
}
|
||||
Reference in New Issue
Block a user