Files
redmine/redmine_ticket_nmi.py
T
2026-10-18 03:18:36 -04:00

632 lines
23 KiB
Python

#!/usr/bin/env python3
"""Bridge Redmine ticket entities → NMI agent CLI (mrscClient-php bin/nmi).
Pattern extract stays in redmine_ticket_entities.py (no NMI I/O).
This module shells out to the NMI CLI for resolve / cs-brief using those hints.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Optional
# Compatibility constants are environment-independent. Resolve overrides at call
# time so a TUI/worker cannot retain a profile captured during module import.
DEFAULT_NMI_BIN = str(Path(__file__).resolve().parent.parent / "mrscClient-php" / "bin" / "nmi")
DEFAULT_ENV_FILE = None # PHP owns default env-file discovery.
DEFAULT_MODE = "production"
def discover_nmi_bin(value: Optional[str] = None, *, environ=None) -> Path:
env = os.environ if environ is None else environ
selected = value or env.get("NMI_CLI")
if selected:
expanded = str(Path(selected).expanduser())
return Path(shutil.which(expanded, path=env.get("PATH", os.defpath)) or expanded).resolve()
found = shutil.which("nmi", path=env.get("PATH", os.defpath))
return Path(found or DEFAULT_NMI_BIN).resolve()
# Entity types that map cleanly into NMI resolve ids
_TRACKING_TYPES = {
"tracking_ups",
"tracking_usps",
"tracking_fedex_candidate",
}
_ORDER_TYPES = {"order_amazon", "order_id"}
_ITEM_TYPES = {"item_no", "item_or_sku"}
_ACCOUNT_TYPES = {"account_ref"}
# NMI deep link templates (from obsidian-ldr/INBOX/WIP/Linking to Entities Within NMI.md)
NMI_BASE = "https://app.ldrprep.com"
NMI_LINKS = {
"item_no": {
"manager": lambda v: f"{NMI_BASE}/?page=itemManager&itemNo={v}",
"statement": lambda v: f"{NMI_BASE}/?page=itemStatement&itemNo={v}",
},
"item_or_sku": {
"manager": lambda v: f"{NMI_BASE}/?page=itemManager&itemNo={v}",
},
"tracking_ups": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
"tracking_usps": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
"tracking_fedex_candidate": {"box_history": lambda v: f"{NMI_BASE}/?page=boxFinder&tracking_number={v}&action=search"},
"order_id": {"order_summary": lambda v: f"{NMI_BASE}/?page=requests&filter=view&id={v}"},
"request_id": {"package_history": lambda v: f"{NMI_BASE}/?page=boxFinder&request_id={v}"},
"account_ref": {"user_info": lambda v: f"{NMI_BASE}/?page=userInfo&accountNo={v}"},
}
# LDR unit item numbers commonly 9 digits starting with 2
_ITEM_NO_BARE = re.compile(r"\b(2\d{8})\b")
@dataclass(frozen=True)
class NmiRuntimeProfile:
"""Resolved NMI execution settings plus a preflight result."""
mode: str
env_file: Optional[Path]
nmi_bin: Path
endpoint: Optional[str]
ready: bool
problem: Optional[str] = None
def resolve_runtime_profile(
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
environ: Optional[dict[str, str]] = None,
) -> NmiRuntimeProfile:
"""Run the actual PHP resolver offline; never equate file existence to readiness.
With no explicit env file, let NMI perform its own documented discovery.
Environment-only credentials are valid. No service requests are performed.
"""
env = dict(os.environ if environ is None else environ)
requested_mode = (mode or env.get("NMI_MODE") or DEFAULT_MODE).strip().lower()
normalized_mode = "production" if requested_mode == "prod" else requested_mode
requested_file = env_file or env.get("NMI_ENV_FILE")
selected_env_file = Path(requested_file).expanduser().resolve() if requested_file else None
selected_nmi_bin = discover_nmi_bin(nmi_bin, environ=env)
def failed(problem):
return NmiRuntimeProfile(normalized_mode, selected_env_file, selected_nmi_bin, endpoint, False, problem)
if normalized_mode not in {"production", "dev"}:
return failed("NMI_MODE must be production or dev.")
if selected_env_file is not None:
try:
if not selected_env_file.is_file():
return failed(f"NMI env file does not exist: {selected_env_file}")
if selected_env_file.stat().st_size == 0:
return failed(f"NMI env file is empty: {selected_env_file}")
except OSError as exc:
return failed(f"Cannot inspect NMI env file: {exc}")
if not selected_nmi_bin.is_file():
return failed(f"NMI CLI does not exist: {selected_nmi_bin}; set NMI_CLI.")
php = shutil.which("php", path=env.get("PATH", os.defpath))
if not php:
return failed("PHP is not on PATH; NMI requires the php CLI.")
cmd = [php, str(selected_nmi_bin), "--doctor", "--json"]
# Preserve NMI env-file mode when no caller/environment mode was supplied.
if mode or env.get("NMI_MODE"):
cmd.append(f"--mode={normalized_mode}")
if selected_env_file:
cmd.append(f"--env-file={selected_env_file}")
if endpoint:
cmd.append(f"--endpoint={endpoint}")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False, env=env)
except subprocess.TimeoutExpired:
return failed("NMI offline doctor timed out after 15 seconds.")
except OSError as exc:
return failed(f"Could not start NMI doctor: {exc}")
try:
data = json.loads(proc.stdout)
if not isinstance(data, dict):
raise ValueError("expected an object")
except (ValueError, TypeError):
return failed(f"NMI doctor returned invalid diagnostic JSON (exit {proc.returncode}); update NMI_CLI to a compatible bin/nmi.")
ready = proc.returncode == 0 and data.get("ready") is True
resolved_file = data.get("env_file")
return NmiRuntimeProfile(
mode=data.get("mode") or normalized_mode,
env_file=Path(resolved_file) if resolved_file else selected_env_file,
nmi_bin=selected_nmi_bin,
endpoint=data.get("endpoint") or endpoint,
ready=ready,
problem=None if ready else (data.get("problem") or f"NMI doctor failed (exit {proc.returncode})."),
)
@dataclass
class NmiLookupPlan:
"""Deduped identifiers to try against NMI, highest confidence first."""
tracking: list[str] = field(default_factory=list)
order_id: list[str] = field(default_factory=list)
item_no: list[str] = field(default_factory=list)
account: list[str] = field(default_factory=list)
skipped: list[dict[str, str]] = field(default_factory=list)
def is_empty(self) -> bool:
return not (self.tracking or self.order_id or self.item_no or self.account)
def enrich_entities_with_item_nos(
entities: list[dict[str, str]], text: str
) -> list[dict[str, str]]:
"""Add bare LDR-style item numbers when not already present."""
seen = {(e.get("type"), (e.get("value") or "").upper()) for e in entities}
out = list(entities)
for m in _ITEM_NO_BARE.finditer(text or ""):
val = m.group(1)
key = ("item_no", val)
if key in seen:
continue
# Avoid treating pure tracking-ish long numbers: item is exactly 9 digits
out.append({"type": "item_no", "value": val, "confidence": "medium"})
seen.add(key)
return out
def plan_from_entities(entities: Iterable[dict[str, str]]) -> NmiLookupPlan:
plan = NmiLookupPlan()
seen_t: set[str] = set()
seen_o: set[str] = set()
seen_i: set[str] = set()
seen_a: set[str] = set()
# Sort high confidence first
ordered = sorted(
entities,
key=lambda e: {"high": 0, "medium": 1, "low": 2}.get(e.get("confidence") or "high", 3),
)
for e in ordered:
kind = e.get("type") or ""
val = (e.get("value") or "").strip()
conf = e.get("confidence") or "high"
if not val:
continue
if kind in _TRACKING_TYPES:
if conf == "low" and kind == "tracking_fedex_candidate":
plan.skipped.append(e)
continue
u = val.upper()
if u not in seen_t:
seen_t.add(u)
plan.tracking.append(val)
elif kind in _ORDER_TYPES:
if val not in seen_o:
seen_o.add(val)
plan.order_id.append(val)
elif kind in _ITEM_TYPES or kind == "item_no":
# Prefer pure numeric item_no; SKU-like may still help as order/item search later
if re.fullmatch(r"\d{6,12}", val):
if val not in seen_i:
seen_i.add(val)
plan.item_no.append(val)
else:
plan.skipped.append({**e, "reason": "sku_not_numeric_item"})
elif kind in _ACCOUNT_TYPES:
if val not in seen_a:
seen_a.add(val)
plan.account.append(val)
else:
plan.skipped.append(e)
return plan
def _php_bin() -> str:
return shutil.which("php") or "php"
def run_nmi(
args: list[str],
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
timeout: int = 60,
) -> subprocess.CompletedProcess[str]:
cmd = [_php_bin(), str(discover_nmi_bin(nmi_bin)), *args]
selected_mode = mode or os.environ.get("NMI_MODE")
selected_file = env_file or os.environ.get("NMI_ENV_FILE")
if selected_mode:
cmd.append(f"--mode={selected_mode}")
if selected_file:
cmd.append(f"--env-file={Path(selected_file).expanduser().resolve()}")
if endpoint:
cmd.append(f"--endpoint={endpoint}")
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 124, "", f"NMI command timed out after {timeout} seconds.")
except OSError as exc:
return subprocess.CompletedProcess(cmd, 127, "", f"Could not start NMI CLI: {exc}")
def try_resolve_ids(
plan: NmiLookupPlan,
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
include_pictures: bool = False,
max_attempts: int = 6,
) -> dict[str, Any]:
"""Try NMI resolve with identifiers from the plan until one succeeds."""
attempts: list[dict[str, Any]] = []
candidates: list[list[str]] = []
for item in plan.item_no:
candidates.append(["resolve", f"--item-no={item}"])
for tn in plan.tracking:
candidates.append(["resolve", f"--tracking={tn}"])
for oid in plan.order_id:
candidates.append(["resolve", f"--order-id={oid}"])
for acct in plan.account:
candidates.append(["resolve", f"--account={acct}"])
if include_pictures:
for c in candidates:
c.append("--pictures")
for args in candidates[:max_attempts]:
proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
attempt = {
"args": args,
"returncode": proc.returncode,
"stderr": (proc.stderr or "").strip()[:500],
}
if proc.returncode == 0 and (proc.stdout or "").strip():
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
attempt["error"] = f"json_decode: {exc}"
attempts.append(attempt)
continue
attempt["ok"] = True
attempts.append(attempt)
return {"ok": True, "result": data, "attempts": attempts, "used_args": args}
attempt["ok"] = False
attempts.append(attempt)
return {"ok": False, "result": None, "attempts": attempts, "used_args": None}
def try_cs_brief(
plan: NmiLookupPlan,
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
max_attempts: int = 6,
) -> dict[str, Any]:
"""Try cs-brief with plan identifiers until one returns text."""
attempts: list[dict[str, Any]] = []
candidates: list[list[str]] = []
for item in plan.item_no:
candidates.append(["cs-brief", f"--item-no={item}"])
for tn in plan.tracking:
candidates.append(["cs-brief", f"--tracking={tn}"])
for oid in plan.order_id:
candidates.append(["cs-brief", f"--order-id={oid}"])
for acct in plan.account:
candidates.append(["cs-brief", f"--account={acct}"])
for args in candidates[:max_attempts]:
proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
attempt = {
"args": args,
"returncode": proc.returncode,
"stderr": (proc.stderr or "").strip()[:500],
}
text = (proc.stdout or "").strip()
if proc.returncode == 0 and text and "=== NMI CS BRIEF ===" in text:
attempt["ok"] = True
attempts.append(attempt)
return {"ok": True, "text": text + ("\n" if not text.endswith("\n") else ""), "attempts": attempts, "used_args": args}
attempt["ok"] = False
if text:
attempt["stdout_preview"] = text[:200]
attempts.append(attempt)
return {"ok": False, "text": None, "attempts": attempts, "used_args": None}
def brief_for_issue(
issue: dict[str, Any],
entities: list[dict[str, str]],
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
with_history: bool = True,
) -> dict[str, Any]:
profile = resolve_runtime_profile(
mode=mode,
env_file=env_file,
nmi_bin=nmi_bin,
endpoint=endpoint,
)
runtime = {
"mode": profile.mode,
"env_file": str(profile.env_file) if profile.env_file else None,
"nmi_bin": str(profile.nmi_bin),
"endpoint": profile.endpoint,
"ready": profile.ready,
"problem": profile.problem,
}
# Prefer public extract path text rebuild without private imports
try:
from redmine_ticket_entities import extract_entities_from_issue as _ex
# rebuild text via extract side-channel: re-run extract on issue already given
# Use journals/subject from issue for bare item_no enrichment only
chunks = []
for key in ("subject", "description"):
if issue.get(key):
chunks.append(str(issue[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"]))
text = "\n".join(chunks)
_ = _ex # silence unused if tree-shaken
except Exception:
text = str(issue.get("subject") or "") + "\n" + str(issue.get("description") or "")
ents = enrich_entities_with_item_nos(entities, text)
plan = plan_from_entities(ents)
out: dict[str, Any] = {
"issue_id": issue.get("id"),
"runtime": runtime,
"plan": {
"tracking": plan.tracking,
"order_id": plan.order_id,
"item_no": plan.item_no,
"account": plan.account,
"skipped": plan.skipped,
},
"entities": ents,
}
if not profile.ready:
out["ok"] = False
out["error"] = "nmi_runtime_not_ready"
return out
if plan.is_empty():
out["ok"] = False
out["error"] = "no_nmi_candidates"
return out
# Execute exactly the profile checked above, not import-time/caller defaults.
mode = profile.mode
env_file = str(profile.env_file) if profile.env_file else None
nmi_bin = str(profile.nmi_bin)
endpoint = profile.endpoint
brief = try_cs_brief(plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
out["brief"] = brief
out["ok"] = bool(brief.get("ok"))
if not out["ok"]:
# still try resolve for structured debug
out["resolve"] = try_resolve_ids(
plan, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint
)
out["error"] = "nmi_lookup_failed"
# Pull item history (new getItemHistory) when requested and item_no present
if with_history:
out = enrich_with_history(out, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint)
return out
def format_private_note(payload: dict[str, Any]) -> str:
"""Build a Redmine private note body from nmi brief result.
Enhanced with:
- Deep NMI links (Item Manager, Box History, Request pages etc.) per the WIP note
- Item history from the new getItemHistory endpoint (billings, who moved, condition changes)
"""
lines = [
"=== Auto NMI pre-fetch (agent) ===",
f"issue_id: {payload.get('issue_id')}",
]
plan = payload.get("plan") or {}
lines.append(
"candidates: "
+ json.dumps(
{
"tracking": plan.get("tracking"),
"order_id": plan.get("order_id"),
"item_no": plan.get("item_no"),
"account": plan.get("account"),
},
sort_keys=True,
)
)
# Deep links from the WIP note
ents = payload.get("entities") or []
links = generate_nmi_links(ents)
if links:
lines.append("")
lines.append("=== NMI Deep Links (Item Manager / Box History / Request pages / userInfo) ===")
lines.extend(links[:12])
brief = payload.get("brief") or {}
if brief.get("ok") and brief.get("text"):
lines.append("")
lines.append(brief["text"].rstrip())
else:
lines.append("")
lines.append("NMI lookup did not resolve cleanly.")
if payload.get("error"):
lines.append(f"error: {payload['error']}")
attempts = brief.get("attempts") or (payload.get("resolve") or {}).get("attempts") or []
for a in attempts[:5]:
lines.append(f"- attempt {a.get('args')}: rc={a.get('returncode')} err={a.get('stderr') or a.get('error')}")
# New history
hist = payload.get("item_history") or {}
if hist.get("ok"):
lines.append("")
lines.append("=== Item History (getItemHistory) ===")
h = hist.get("history") or {}
if isinstance(h, dict) and h:
lines.append(json.dumps(h, indent=2, default=str)[:1600])
elif hist.get("text"):
lines.append(hist["text"][:1400])
else:
lines.append("(see raw for full history)")
elif "item_history" in payload:
lines.append("")
lines.append("Item history note: " + str((payload.get("item_history") or {}).get("error", "no data")))
lines.append("")
lines.append("_Generated by redmine_ticket_nmi; verify before customer-facing use._")
return "\n".join(lines) + "\n"
def generate_nmi_links(entities: list[dict[str, str]]) -> list[str]:
"""Produce a list of useful deep links based on resolved entities."""
links = []
seen = set()
for e in entities:
kind = e.get("type")
val = e.get("value")
if not kind or not val:
continue
templates = NMI_LINKS.get(kind, {})
for label, tmpl in templates.items():
url = tmpl(val)
key = (kind, val, label)
if key in seen:
continue
seen.add(key)
links.append(f"- {label} for {kind} {val}: {url}")
return links
def try_item_history(
item_no: str,
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
limit: int = 50,
snapshot: bool = True,
) -> dict[str, Any]:
"""Call nmi item history for a single item_no. Returns structured result."""
args = ["item", "history", f"--item-no={item_no}", f"--limit={limit}"]
if snapshot:
args.append("--snapshot")
try:
res = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, endpoint=endpoint, timeout=30)
cmd = res.args
if res.returncode != 0:
return {"ok": False, "error": res.stderr.strip() or res.stdout.strip(), "cmd": cmd}
# Try JSON first
try:
data = json.loads(res.stdout)
return {"ok": True, "history": data, "raw": res.stdout}
except Exception:
return {"ok": True, "text": res.stdout.strip(), "raw": res.stdout}
except Exception as exc:
return {"ok": False, "error": str(exc)}
def enrich_with_history(
payload: dict[str, Any],
*,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
endpoint: Optional[str] = None,
) -> dict[str, Any]:
"""If an item_no is present in the plan, fetch its history and attach."""
plan = payload.get("plan") or {}
item_no = plan.get("item_no")
if not item_no:
return payload
hist = try_item_history(
str(item_no[0] if isinstance(item_no, list) else item_no),
mode=mode,
env_file=env_file,
nmi_bin=nmi_bin,
endpoint=endpoint,
limit=60,
snapshot=True,
)
payload["item_history"] = hist
return payload
def try_auto_link_redmine_contact(
*,
contact_id: int,
tracking: list[str] | None = None,
order_id: list[str] | None = None,
item_no: list[str] | None = None,
mode: Optional[str] = None,
env_file: Optional[str] = None,
nmi_bin: Optional[str] = None,
) -> dict[str, Any]:
"""If we have a Redmine contact_id and identifiers from a ticket, try to resolve in NMI
and automatically create the redmine_contact_links entry.
This implements the pattern: ticket mentions tracking/order/item -> successful
cs-info/resolve -> the returned customer owns the shipment -> link the Redmine
contact to that NMI user_account using the new junction table.
"""
if not contact_id:
return {"ok": False, "reason": "no_contact_id"}
ids: list[str] = []
if tracking:
for t in tracking:
ids.extend(["--tracking", str(t)])
if order_id:
for o in order_id:
ids.extend(["--order-id", str(o)])
if item_no:
for i in item_no:
ids.extend(["--item-no", str(i)])
if not ids:
return {"ok": False, "reason": "no_identifiers"}
args = ["link-contact", "--redmine-contact-id", str(contact_id), *ids]
try:
proc = run_nmi(args, mode=mode, env_file=env_file, nmi_bin=nmi_bin, timeout=90)
cmd = proc.args
ok = proc.returncode == 0
result = {
"ok": ok,
"contact_id": contact_id,
"cmd": " ".join(cmd),
"stdout": (proc.stdout or "")[:2500],
"stderr": (proc.stderr or "")[:800],
}
if ok and proc.stdout:
try:
if proc.stdout.strip().startswith("{"):
result["parsed"] = json.loads(proc.stdout)
except Exception:
pass
return result
except Exception as e:
return {"ok": False, "contact_id": contact_id, "error": str(e)}