Agentic ERP supply-chain copilot
A planner asks a supply question in plain English. The system does the arithmetic exactly rather than guessing at it, and stops for a manager on anything above $10,000.
The problem
Supply-chain planners ask questions in sentences. Optimization solvers read constraint matrices. Put a language model between the two and let it do the math, and it returns plausible answers that cost money when they're wrong. A routing plan that looks optimal and isn't shows up later as a real invoice.
Architecture
A LangGraph StateGraph routes each query through 6 nodes. The intent classifier maps natural language to 1 of 10 bounded intents (mcnf_solve, jsp_schedule, vrp_route, robust_allocate, meio_optimize, bullwhip_analyze, disruption_resource, kg_query, contract_query, multi_step). A DSPy-compiled classifier (MIPROv2) takes over from the zero-shot baseline at runtime when its JSON artifact is present, and the zero-shot path stays available with no config change.
The Think-on-Graph agent extracts entities, selects relations from a whitelist, and traverses Neo4j with parameterized Cypher. The CRAG stack (BGE-large-en-v1.5 dense search, BM25 sparse search, RRF at k=60, and a CrossEncoder reranker) retrieves contract chunks and labels each one Correct, Ambiguous, or Incorrect before the synthesizer sees it. A two-level Redis semantic cache skips the LangGraph traversal entirely on a repeat query: SHA-256 exact match first, then cosine similarity at 0.95 with a discriminating-token guard for numbers and entity codes.
When a solver result exceeds $10,000, the graph pauses at a human-gate node via LangGraph interrupt(). The decision UUID sits in Redis with a 24-hour TTL. The approve endpoint resumes the graph with the manager's decision and guards against a double submission (HTTP 409 on re-submission). In the browser, the planner sees a warning banner with Approve and Reject buttons that resolve into a green or red outcome badge.
Stack
| Layer | Tool | Why |
|---|---|---|
| Orchestration | LangGraph StateGraph | 6-node graph: classify → route → {kg_agent | contract_agent | solver_dispatch} → human_gate (conditional) → synthesize. A MemorySaver checkpointer holds the paused run across the human-in-the-loop interrupt. |
| Intent classification | Zero-shot with DSPy opt-in | The primary path is a structured-output call with 10 bounded intents and a confidence threshold. The optional upgrade is a DSPy MIPROv2-compiled ChainOfThought loaded from a JSON artifact, and the zero-shot path stays in place when DSPy is missing or the artifact is absent. |
| Retrieval (CRAG) | BGE-large-en-v1.5, BM25, RRF | 1024-dim dense search via pgvector ivfflat and BM25Okapi sparse search, fused with RRF (k=60), then a CrossEncoder reranker and an LLM relevance gate that labels each passage Correct, Ambiguous, or Incorrect. |
| Knowledge graph | Neo4j 5, whitelisted Cypher | Think-on-Graph: extract entities, select relations from a whitelist, traverse with 1 retry that self-corrects on an empty subgraph. No raw model text reaches a database query. |
| Solvers | OR-Tools, CVXPY, SciPy SLSQP | 7 exact solvers: MCNF (LP), VRP (CVRP), JSP, robust min-max (CVXPY), MEIO GSM (SLSQP, non-convex), bullwhip analysis, and disruption resourcing. One solver per intent, and the language model does none of the math. |
| MCP contracts | 6 FastMCP servers | server_erp, server_kg, server_crag, server_ortools, server_cvxpy, server_scipy. Every solver and database call crosses a typed Pydantic contract, so malformed output picks the wrong tool at worst. |
| Semantic cache | Redis with BGE cosine (0.95) | Two-level lookup: SHA-256 exact match, then cosine similarity. A discriminating-token guard (number multiset plus entity code set) blocks false cache hits when 2 queries share an embedding but differ in their parameters. |
| Security | JWT, RBAC, PII sanitizer | 3 roles: viewer (kg_query, contract_query), analyst (all solvers), admin (full). A regex scrubber strips emails, phone numbers, and national IDs before any text enters the model context. JWT HS256, 60-minute expiry. |
| CI / CD | 6-job GitHub Actions pipeline | ruff, black, mypy, bandit, and pytest unit tests → frontend tsc → integration tests → red-team (promptfoo) → build and push to ACR with OIDC → repository_dispatch to the deployment repo. |
| Fine-tune | DPO and QLoRA on Llama 3.1-8B | LangSmith production traces converted into preference pairs. LoRA r=16 on all projection layers. Queued for a Lightning AI L4 GPU run. |
What I learned
The hardest boundary to enforce sits where model output turns into executable code, well before the agent graph. Once every solver call crossed a typed Pydantic MCP contract and every Cypher query came from a whitelisted template, whole classes of failure stopped appearing. The model can still misread a request and pick the wrong tool, which a person catches. It can't form a malformed query.
The discriminating-token guard on the semantic cache was the fix I didn't expect to need. BGE embeddings wash out the difference between "allocate 400 units" and "allocate 1000 units": cosine similarity clears 0.95, and the correct answers are nothing alike. The guard compares the numbers and entity codes in both queries and refuses the cached response when they differ.