174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
#!/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)
|