Files
redmine/redmine_ticket_entities.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

174 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Extract common CS entity identifiers from ticket text (pattern-based MVP).
Does not call NMI. Purpose: surface tracking numbers / order-like IDs / SKUs
so humans and agents can jump systems without copy-paste gymnastics.
Patterns are conservative heuristics — false positives are possible; treat hits
as hints, not ground truth.
"""
from __future__ import annotations
import re
from typing import Any, Iterable
# USPS: 20–22 digit, or 420… + 26 digits, or 13-char ending US
_USPS = re.compile(
r"\b("
r"9[0-9]{19,21}"
r"|420\d{5}\d{20,26}"
r"|[A-Z]{2}\d{9}US"
r")\b",
re.I,
)
_UPS = re.compile(r"\b(1Z[0-9A-Z]{16})\b", re.I)
_FEDEX = re.compile(r"\b(\d{12}|\d{15})\b")
_AMZ_ORDER = re.compile(r"\b(\d{3}-\d{7}-\d{7})\b")
# Prefer digit-heavy order captures; reject pure alpha HTML fragments
_ORDER_LABEL = re.compile(
r"(?:order\s*(?:id|#|number|no\.?)|po\s*(?:#|number)?)\s*[:=\s#]*([A-Z0-9][A-Z0-9\-_/]{3,32})",
re.I,
)
_ITEM_LABEL = re.compile(
r"(?:item\s*(?:#|no\.?|number)|sku|merchant\s*sku)\s*[:=\s#]*([A-Z0-9][A-Z0-9\-_/]{2,40})",
re.I,
)
_ACCOUNT = re.compile(r"\baccount\s*[#:]?\s*([A-Z0-9]{4,20})\b", re.I)
# LDR unit item numbers: 9 digits starting with 2
_ITEM_NO_BARE = re.compile(r"\b(2\d{8})\b")
_ORDER_STOP = {
"number",
"id",
"no",
"none",
"null",
"true",
"false",
"class",
"style",
"mode",
"text",
"title",
"guide",
"html",
"body",
"rted",
"rtal",
"licy",
"lation-mode",
"rtant",
"nsive",
"nsive-text",
"nsive-title",
"nsive-h2",
"ints",
}
def _text_from_issue(issue: dict[str, Any]) -> str:
chunks: list[str] = []
for key in ("subject", "description"):
val = issue.get(key)
if val:
chunks.append(str(val))
helpdesk = issue.get("helpdesk_ticket")
if isinstance(helpdesk, dict):
for key in ("from_address", "to_address", "message_id"):
if helpdesk.get(key):
chunks.append(str(helpdesk[key]))
contact = helpdesk.get("contact")
if isinstance(contact, dict):
for key in ("email", "company", "name", "first_name", "last_name"):
if contact.get(key):
chunks.append(str(contact[key]))
journals = issue.get("journals")
if isinstance(journals, list):
for j in journals[-15:]:
if isinstance(j, dict) and j.get("notes"):
chunks.append(str(j["notes"]))
return "\n".join(chunks)
def extract_entities(text: str) -> list[dict[str, str]]:
"""Return unique {type, value, confidence} hits from free text."""
hits: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
def add(kind: str, value: str, confidence: str = "high") -> None:
value = value.strip()
if not value:
return
key = (kind, value.upper())
if key in seen:
return
seen.add(key)
hits.append({"type": kind, "value": value, "confidence": confidence})
for m in _UPS.finditer(text):
add("tracking_ups", m.group(1).upper())
for m in _USPS.finditer(text):
add("tracking_usps", m.group(1).upper())
for m in _AMZ_ORDER.finditer(text):
add("order_amazon", m.group(1))
for m in _ORDER_LABEL.finditer(text):
val = m.group(1)
if re.fullmatch(r"\d{3}-\d{7}-\d{7}", val):
add("order_amazon", val)
continue
if not re.search(r"\d", val):
continue
if val.lower() in _ORDER_STOP:
continue
# Require at least 5 chars and a digit; reject long alpha+token noise
if len(val) < 5:
continue
if re.search(r"[a-zA-Z]{6,}", val) and not re.search(r"\d{4,}", val):
continue # e.g. MGdHZfSeDzi_BhM6 without enough digits
conf = "high" if re.fullmatch(r"\d{5,12}", val) else "medium"
add("order_id", val, confidence=conf)
for m in _ITEM_LABEL.finditer(text):
val = m.group(1)
if re.fullmatch(r"2\d{8}", val):
add("item_no", val, confidence="high")
elif re.search(r"\d", val) or re.search(r"[A-Z]{2,}", val, re.I):
add("item_or_sku", val, confidence="medium")
for m in _ITEM_NO_BARE.finditer(text):
add("item_no", m.group(1), confidence="medium")
for m in _ACCOUNT.finditer(text):
val = m.group(1)
if val.lower() in {"within", "number", "setup", "your", "the"}:
continue
if not re.search(r"\d", val) and len(val) < 6:
continue
add("account_ref", val, confidence="medium")
if re.search(r"\b(fedex|tracking|shipment|shipped|carrier)\b", text, re.I):
for m in _FEDEX.finditer(text):
# skip USPS/item_no already captured lengths that are tracking
val = m.group(1)
if val.startswith("9") and len(val) >= 20:
continue
if re.fullmatch(r"2\d{8}", val):
continue
add("tracking_fedex_candidate", val, confidence="low")
return hits
def extract_entities_from_issue(issue: dict[str, Any]) -> list[dict[str, str]]:
return extract_entities(_text_from_issue(issue))
def format_entities_line(entities: Iterable[dict[str, str]]) -> str:
parts = []
for e in entities:
conf = e.get("confidence") or "high"
if conf == "high":
parts.append(f"{e['type']}:{e['value']}")
else:
parts.append(f"{e['type']}:{e['value']}({conf})")
return "; ".join(parts)