251 lines
7.9 KiB
Python
251 lines
7.9 KiB
Python
"""相似工单候选生成。
|
|
|
|
相似结果只用于非阻断提示,不合并工单,也不改变正式工单的创建和状态流转。
|
|
"""
|
|
|
|
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,
|
|
)
|