Files
2026-07-27 18:59:28 +08:00

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,
)