Organized
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user