#!/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)