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 __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.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
|
// Only accept GET requests
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
@@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
// Get title parameter
|
// Get title parameter
|
||||||
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||||
|
|
||||||
if (strlen($title) < 5) {
|
ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]);
|
||||||
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]);
|
|
||||||
|
|||||||
@@ -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