feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

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