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