Fix the possible-duplicates finder; share it as SimilarTicketService (#113)
check_duplicates.php's logic moves to services/SimilarTicketService.php (used next by the MCP find_similar_tickets tool). Fixes found while testing it: - Non-admins never got matches: the query had no `t` alias but the visibility filter's SQL uses t.*, so it failed and the error was swallowed into "no duplicates". - The word-overlap scoring could never fire, since candidates were only whole-title substring or SOUNDEX matches. Tickets sharing a significant word (4+ letters) are now candidates too; overlap needs 2+ shared words so one common word (e.g. every automated ticket's [problem] tag) isn't a match. - SOUNDEX compared PHP's 4-char code (effectively the first word) with MariaDB's full-length code: the SQL side almost never matched, and the scorer marked any two titles sharing a first word as "sounds alike". Both sides now use SQL SOUNDEX on the full title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
@@ -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)]);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Finding open tickets whose titles look like a given title (LIKE + SOUNDEX +
|
||||
* word overlap), limited to tickets the user can see, best match first.
|
||||
*
|
||||
* Shared by the web UI (api/check_duplicates.php, the ticket page's
|
||||
* "possible duplicates" list) and the MCP find_similar_tickets tool.
|
||||
* Extracted from check_duplicates.php.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
class SimilarTicketService
|
||||
{
|
||||
/**
|
||||
* @param array $currentUser Authenticated user row (for visibility)
|
||||
* @return array Matches: ticket_id, title, status, priority, created_at, similarity (0-100)
|
||||
*/
|
||||
public static function find(mysqli $conn, array $currentUser, string $title, int $limit = 5): array
|
||||
{
|
||||
$title = trim($title);
|
||||
|
||||
if (strlen($title) < 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $title . '%';
|
||||
$titleWords = self::words($title);
|
||||
|
||||
// Build visibility filter so users only see titles they have access to
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$visFilter = $ticketModel->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<string>
|
||||
*/
|
||||
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 !== ''
|
||||
)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user