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
152 lines
6.1 KiB
PHP
152 lines
6.1 KiB
PHP
<?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 !== ''
|
|
)));
|
|
}
|
|
}
|