"""相似工单候选生成。 相似结果只用于非阻断提示,不合并工单,也不改变正式工单的创建和状态流转。 """ import re from collections.abc import Iterable from difflib import SequenceMatcher from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity from django.db import connection, transaction from django.db.models import F, Q from django.db.models.functions import Greatest from apps.tickets.models import Ticket, TicketSimilaritySuggestion ALGORITHM_VERSION = "fts_trigram_v2_context" MIN_CANDIDATE_SCORE = 0.12 def _normalize_text(value: str) -> str: return re.sub(r"\s+", " ", value.casefold()).strip() def _terms(value: str) -> set[str]: normalized = _normalize_text(value) words = set(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}|[\u4e00-\u9fff]{2,}", normalized)) chinese = "".join(re.findall(r"[\u4e00-\u9fff]", normalized)) words.update(chinese[index : index + 2] for index in range(max(len(chinese) - 1, 0))) return {term for term in words if term} def _keyword_similarity(left: str, right: str) -> float: left_terms = _terms(left) right_terms = _terms(right) if not left_terms or not right_terms: return 0.0 return len(left_terms & right_terms) / len(left_terms | right_terms) def _excerpt(text: str, query: str, limit: int = 180) -> str: normalized = text.strip() if len(normalized) <= limit: return normalized terms = sorted(_terms(query), key=len, reverse=True) position = next( (normalized.casefold().find(term) for term in terms if term in normalized.casefold()), 0, ) start = max(position - limit // 3, 0) end = min(start + limit, len(normalized)) prefix = "…" if start else "" suffix = "…" if end < len(normalized) else "" return f"{prefix}{normalized[start:end]}{suffix}" def _serialize_candidate( ticket: Ticket, *, score: float, title_similarity: float, keyword_score: float, query: str, ) -> dict: return { "ticket": ticket, "score": max(0.0, min(float(score), 1.0)), "title_similarity": max(0.0, min(float(title_similarity), 1.0)), "keyword_score": max(0.0, min(float(keyword_score), 1.0)), "matched_excerpt": _excerpt(ticket.description, query), } def _postgres_candidates( queryset, *, title: str, query_text: str, limit: int, ) -> list[dict]: search_query = SearchQuery(query_text, config="simple", search_type="plain") search_vector = ( SearchVector("title", config="simple", weight="A") + SearchVector("description", config="simple", weight="B") + SearchVector("problem_performance", config="simple", weight="B") + SearchVector("error_information", config="simple", weight="B") + SearchVector("product_name", config="simple", weight="C") + SearchVector("module_name", config="simple", weight="C") + SearchVector("related_system", config="simple", weight="C") + SearchVector("attempted_actions", config="simple", weight="C") + SearchVector("customer_request", config="simple", weight="C") + SearchVector("current_result", config="simple", weight="C") ) rows = ( queryset.annotate( keyword_score=SearchRank(search_vector, search_query), title_similarity=TrigramSimilarity("title", title), ) .annotate(score=Greatest(F("title_similarity"), F("keyword_score"))) .filter(Q(title_similarity__gte=MIN_CANDIDATE_SCORE) | Q(keyword_score__gte=0.05)) .order_by("-score", "-updated_at")[:limit] ) return [ _serialize_candidate( ticket, score=ticket.score, title_similarity=ticket.title_similarity, keyword_score=ticket.keyword_score, query=query_text, ) for ticket in rows ] def _portable_candidates( queryset: Iterable[Ticket], *, title: str, query_text: str, limit: int, ) -> list[dict]: normalized_title = _normalize_text(title) candidates = [] for ticket in queryset: title_similarity = SequenceMatcher( None, normalized_title, _normalize_text(ticket.title), ).ratio() keyword_score = _keyword_similarity( query_text, " ".join( [ ticket.title, ticket.description, ticket.problem_performance, ticket.error_information, ticket.product_name, ticket.module_name, ticket.related_system, ticket.attempted_actions, ticket.customer_request, ticket.current_result, *ticket.tags, ] ), ) score = max(title_similarity, keyword_score) if score < MIN_CANDIDATE_SCORE: continue candidates.append( _serialize_candidate( ticket, score=score, title_similarity=title_similarity, keyword_score=keyword_score, query=query_text, ) ) return sorted( candidates, key=lambda item: (item["score"], item["ticket"].updated_at), reverse=True, )[:limit] def find_similar_tickets(ticket: Ticket, *, limit: int = 5) -> list[dict]: """返回可解释的相似候选,不修改或合并任何工单。""" title = ticket.title.strip() or ticket.description[:200] query_text = " ".join( value for value in [ ticket.title, ticket.description, ticket.problem_performance, ticket.error_information, ticket.product_name, ticket.module_name, ticket.related_system, ticket.attempted_actions, ticket.customer_request, ticket.current_result, *ticket.tags, ] if value ).strip() if not query_text: return [] queryset = Ticket.objects.filter(archived_at__isnull=True).exclude(id=ticket.id).select_related( "category" ) if connection.vendor == "postgresql": return _postgres_candidates( queryset, title=title, query_text=query_text, limit=limit, ) return _portable_candidates( queryset, title=title, query_text=query_text, limit=limit, ) @transaction.atomic def refresh_ticket_similarity_suggestions( ticket: Ticket, *, limit: int = 5, ) -> list[TicketSimilaritySuggestion]: """原子替换指定工单的相似候选快照。""" ticket = Ticket.objects.select_for_update().get(id=ticket.id) candidates = find_similar_tickets(ticket, limit=limit) TicketSimilaritySuggestion.objects.filter( source_ticket=ticket, algorithm_version=ALGORITHM_VERSION, ).delete() TicketSimilaritySuggestion.objects.bulk_create( [ TicketSimilaritySuggestion( source_ticket=ticket, candidate_ticket=item["ticket"], score=item["score"], title_similarity=item["title_similarity"], keyword_score=item["keyword_score"], matched_excerpt=item["matched_excerpt"], algorithm_version=ALGORITHM_VERSION, ) for item in candidates ] ) return list( TicketSimilaritySuggestion.objects.filter( source_ticket=ticket, algorithm_version=ALGORITHM_VERSION, ).select_related("candidate_ticket__category") ) def schedule_ticket_similarity_refresh(ticket: Ticket) -> None: transaction.on_commit( lambda ticket_id=ticket.id: refresh_ticket_similarity_suggestions( Ticket.objects.get(id=ticket_id) ), robust=True, )