Files
tinker_tickets/services/SimilarTicketService.php
T

152 lines
6.1 KiB
PHP
Raw Normal View History

<?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 !== ''
)));
}
}