diff --git a/api/check_duplicates.php b/api/check_duplicates.php index 1cb85f8..771545e 100644 --- a/api/check_duplicates.php +++ b/api/check_duplicates.php @@ -8,7 +8,7 @@ require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/helpers/ResponseHelper.php'; -require_once dirname(__DIR__) . '/models/TicketModel.php'; +require_once dirname(__DIR__) . '/services/SimilarTicketService.php'; // Only accept GET requests if ($_SERVER['REQUEST_METHOD'] !== 'GET') { @@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') { // Get title parameter $title = isset($_GET['title']) ? trim($_GET['title']) : ''; -if (strlen($title) < 5) { - ResponseHelper::success(['duplicates' => []]); -} - -// Search for similar titles -// Use both LIKE for substring matching and SOUNDEX for phonetic matching -$duplicates = []; - -// Prepare search term for LIKE -$searchTerm = '%' . $title . '%'; - -// Get SOUNDEX of title -$soundexTitle = soundex($title); - -// Build visibility filter so users only see titles they have access to -$ticketModel = new TicketModel($conn); -$visFilter = $ticketModel->getVisibilityFilter($currentUser); - -// First, search for exact substring matches (case-insensitive) -$sql = "SELECT ticket_id, title, status, priority, created_at - FROM tickets - WHERE ( - title LIKE ? - OR SOUNDEX(title) = ? - ) - AND status != 'Closed' - AND ({$visFilter['sql']}) - ORDER BY created_at DESC - LIMIT 10"; - -$types = "ss" . $visFilter['types']; -$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']); - -// Duplicate detection is advisory (it must not block ticket creation), so on any -// DB error degrade gracefully to "no duplicates" rather than fataling the request. -// mysqli may throw (default exception mode) or return false depending on config. -try { - $stmt = $conn->prepare($sql); - if (!$stmt) { - throw new RuntimeException('prepare failed: ' . $conn->error); - } - if (!empty($params)) { - $stmt->bind_param($types, ...$params); - } - $stmt->execute(); - $result = $stmt->get_result(); - if ($result === false) { - // Non-exception mysqli mode: execute/get_result return false instead of - // throwing. Treat as a query failure so we don't fatal on $result below. - throw new RuntimeException('query failed: ' . $conn->error); - } -} catch (Throwable $e) { - error_log('check_duplicates: ' . $e->getMessage()); - ResponseHelper::success(['duplicates' => []]); -} - -while ($row = $result->fetch_assoc()) { - // Calculate similarity score - $similarity = 0; - - // Check for exact substring match - if (stripos($row['title'], $title) !== false) { - $similarity = 90; - // Check SOUNDEX match - } elseif (soundex($row['title']) === $soundexTitle) { - $similarity = 70; - // Check word overlap - } else { - $titleWords = array_map('strtolower', preg_split('/\s+/', $title)); - $rowWords = array_map('strtolower', preg_split('/\s+/', $row['title'])); - $matchingWords = array_intersect($titleWords, $rowWords); - $similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60; - } - - if ($similarity >= 30) { - $duplicates[] = [ - 'ticket_id' => $row['ticket_id'], - 'title' => $row['title'], - 'status' => $row['status'], - 'priority' => $row['priority'], - 'created_at' => $row['created_at'], - 'similarity' => round($similarity) - ]; - } -} - -$stmt->close(); - -// Sort by similarity descending -usort($duplicates, function ($a, $b) { - return $b['similarity'] - $a['similarity']; -}); - -// Limit to top 5 -$duplicates = array_slice($duplicates, 0, 5); - -ResponseHelper::success(['duplicates' => $duplicates]); +ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]); diff --git a/services/SimilarTicketService.php b/services/SimilarTicketService.php new file mode 100644 index 0000000..76d0562 --- /dev/null +++ b/services/SimilarTicketService.php @@ -0,0 +1,151 @@ +getVisibilityFilter($currentUser); + + // Candidates: the whole title as a substring, a SOUNDEX match, or any + // significant word (4+ letters) in common. The scoring below decides + // what counts as similar; without the word candidates its + // word-overlap branch could never match anything, so e.g. "Printer + // jammed again" never surfaced "Printer is jammed". + $words = array_slice(array_values(array_filter($titleWords, fn($w) => mb_strlen($w) >= 4)), 0, 8); + $wordSql = str_repeat(' OR t.title LIKE ?', count($words)); + $wordParams = array_map(fn($w) => '%' . addcslashes($w, '%_\\') . '%', $words); + + // Aliased as `t`: the visibility filter's SQL refers to t.* columns. + // Without the alias the query failed for every non-admin and the + // error was swallowed below, so they never saw any matches. + // SOUNDEX is compared in SQL on both sides: PHP's soundex() keeps only + // 4 characters (effectively the first word), which never equalled + // MariaDB's full-length code in the WHERE and, in the scoring, made any + // two titles sharing a first word score as "sounds alike". + $sql = "SELECT t.ticket_id, t.title, t.status, t.priority, t.created_at, + SOUNDEX(t.title) = SOUNDEX(?) AS sounds_alike + FROM tickets t + WHERE ( + t.title LIKE ? + OR SOUNDEX(t.title) = SOUNDEX(?){$wordSql} + ) + AND t.status != 'Closed' + AND ({$visFilter['sql']}) + ORDER BY t.created_at DESC + LIMIT 50"; + + $types = "sss" . str_repeat('s', count($words)) . $visFilter['types']; + $params = array_merge([$title, $searchTerm, $title], $wordParams, $visFilter['params']); + + // Duplicate detection is advisory (it must not block ticket creation), so on any + // DB error degrade gracefully to "no duplicates" rather than fataling the request. + // mysqli may throw (default exception mode) or return false depending on config. + try { + $stmt = $conn->prepare($sql); + if (!$stmt) { + throw new RuntimeException('prepare failed: ' . $conn->error); + } + if (!empty($params)) { + $stmt->bind_param($types, ...$params); + } + $stmt->execute(); + $result = $stmt->get_result(); + if ($result === false) { + // Non-exception mysqli mode: execute/get_result return false instead of + // throwing. Treat as a query failure so we don't fatal on $result below. + throw new RuntimeException('query failed: ' . $conn->error); + } + } catch (Throwable $e) { + error_log('check_duplicates: ' . $e->getMessage()); + return []; + } + + while ($row = $result->fetch_assoc()) { + // Calculate similarity score + $similarity = 0; + + // Check for exact substring match + if (stripos($row['title'], $title) !== false) { + $similarity = 90; + // Check SOUNDEX match + } elseif (!empty($row['sounds_alike'])) { + $similarity = 70; + // Check word overlap + } else { + // At least two shared words: one common word (e.g. the + // "[problem]" tag every automated ticket carries) is not + // similarity, however short the searched title is. + $matchingWords = array_intersect($titleWords, self::words($row['title'])); + if (count($matchingWords) >= 2) { + $similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60; + } + } + + if ($similarity >= 30) { + $duplicates[] = [ + 'ticket_id' => $row['ticket_id'], + 'title' => $row['title'], + 'status' => $row['status'], + 'priority' => $row['priority'], + 'created_at' => $row['created_at'], + 'similarity' => round($similarity) + ]; + } + } + + $stmt->close(); + + // Sort by similarity descending + usort($duplicates, function ($a, $b) { + return $b['similarity'] - $a['similarity']; + }); + + // Keep the best matches + return array_slice($duplicates, 0, $limit); + } + + /** + * Distinct lowercase words of a title, split on anything that isn't a + * letter or digit (so "[ceph]" and "ceph" are the same word). + * + * @return list + */ + private static function words(string $title): array + { + return array_values(array_unique(array_filter( + preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($title)), + fn($w) => $w !== '' + ))); + } +}