|
|
||
|---|---|---|
| .github/workflows | ||
| data | ||
| docs | ||
| infra | ||
| prompts | ||
| scripts | ||
| src | ||
| streamlit | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Supply Chain Multi-Agent System
A production-grade multi-agent system for CPG supply chain optimization. Three specialized AI agents — demand forecasting, inventory optimization, and supplier risk assessment — collaborate through a LangGraph state machine with typed state, conditional human-in-the-loop approval, and full LLM observability through self-hosted Langfuse.
Built to demonstrate end-to-end agentic AI platform engineering: from versioned prompt templates and RAG pipelines to containerized deployment with Azure IaC.
Demo flow (qwen2.5:3b, 1050 Ti): supplier RAG query with source citations → inventory reorder-point query → interrupt() fires for human approval → approved → synthesised response.
Architecture
graph TD
UI[Streamlit Chat UI<br/>:8501] -->|POST /api/v1/query| API[FastAPI<br/>:8000]
API --> Graph[LangGraph StateGraph]
Graph --> Router[Router Node]
Router -->|demand| Demand[Demand Forecaster]
Router -->|inventory| Inventory[Inventory Optimizer]
Router -->|supplier| Supplier[Supplier Risk Monitor]
Demand --> Synthesis[Synthesis Node]
Inventory -->|needs reorder| Pause{{"⏸ interrupt()"}}
Pause -->|approved / rejected| Synthesis
Supplier --> Synthesis
Synthesis --> Guard[Output Guard<br/>PII + Prompt-leak + LLM Judge]
Guard --> API
Demand --> Tools[Sales Tools<br/>forecast, seasonality]
Inventory --> InvTools[Inventory Tools<br/>stock levels, reorder point]
Supplier --> SupTools[Supplier Tools<br/>scorecard, lead times]
Supplier --> RAG[RAG Pipeline<br/>LlamaIndex + FAISS]
API --> Redis[(Redis<br/>Checkpointer)]
API --> Ollama[Ollama LLM<br/>llama3.1:8b]
Graph -.-> Langfuse[Langfuse<br/>Observability]
style Router fill:#c084fc,stroke:#a855f7,color:#1a1a2e,stroke-width:2px
style Demand fill:#60a5fa,stroke:#3b82f6,color:#1a1a2e,stroke-width:2px
style Inventory fill:#4ade80,stroke:#22c55e,color:#1a1a2e,stroke-width:2px
style Supplier fill:#f87171,stroke:#ef4444,color:#1a1a2e,stroke-width:2px
style Pause fill:#fbbf24,stroke:#f59e0b,color:#1a1a2e,stroke-width:2px
style Guard fill:#ec4899,stroke:#db2777,color:#1a1a2e,stroke-width:2px
How It Works
- User submits a natural language query via the Streamlit chat UI or REST API
- Router classifies intent into one of three domains: demand, inventory, or supplier
- Specialist agent executes with bound tools and (for supplier) RAG search
- Inventory agent conditionally interrupts the graph when a reorder is recommended, pausing for human approval via
interrupt() - Synthesis node merges agent output, tool call results, and RAG citations into a coherent response
- Output guard inspects the synthesised response: regex rules block PII/prompt leaks (hard refusal with side-channel scrub); an LLM judge annotates suspected fabrication and missing citations (soft warning)
- Langfuse captures the full trace: router decision, tool calls, LLM tokens, latency, and
output_guard.regex/output_guard.llm_judgesub-spans
Agents
Demand Forecaster
Analyses historical sales data and generates demand forecasts. Uses three bound tools (query_sales_csv, calculate_forecast, get_seasonality) to pull real data before responding — never guesses at numbers.
Example queries:
- "What's the demand forecast for Tide Pods next quarter?"
- "Which SKUs have the strongest seasonal patterns?"
- "Show me year-over-year sales trends for Baby Care"
Inventory Optimizer
Monitors stock levels and calculates statistical reorder points (Z=1.65, 95% service level). Conditionally triggers human-in-the-loop approval via LangGraph's interrupt() when a reorder is recommended — simple informational queries ("what's our stock?") do not interrupt.
Example queries:
- "What are the current stock levels for Crest 3D White?"
- "Should we reorder Charmin Ultra Soft?" (triggers approval flow)
- "Which SKUs are below their minimum stock level?"
Supplier Risk Monitor
Assesses supplier performance using scorecard data, lead time statistics, and RAG-retrieved documentation. The only agent with access to the knowledge base — searches supplier SOPs, quality standards, and shipping manifests with source citations.
Example queries:
- "What's the risk assessment for Acme Chemical Supply?"
- "Which suppliers have on-time rates below 85%?"
- "What does the quality inspection report say about temperature requirements?"
Human-in-the-Loop Approval
The inventory agent uses LangGraph's interrupt() function for conditional human approval. This is a production-grade pattern — not a blanket confirmation dialog:
User: "Should we reorder Charmin Ultra Soft?"
└─ Router: intent=inventory
└─ Inventory Agent:
├─ check_stock_levels(sku="SKU-007") → current=4800, min=5000 ⚠️
├─ calculate_reorder_point(sku="SKU-007") → needs_reorder=True
└─ interrupt({action: "reorder", sku: "SKU-007", reorder_point: 6250})
└─ Graph PAUSED — waiting for approval
└─ Streamlit polls GET /api/v1/approvals/pending
└─ User clicks [Approve] or [Reject]
└─ POST /api/v1/approvals/{id} → Command(resume=...)
└─ Graph RESUMES → Synthesis → Response
The graph state is persisted in Redis via RedisSaver, so approvals survive server restarts.
RAG Pipeline
The RAG pipeline uses LlamaIndex for document ingestion and FAISS for vector similarity search, with sentence-transformers (all-MiniLM-L6-v2) providing BERT-based embeddings.
data/docs/*.md → SentenceSplitter (512 tokens, 50 overlap)
→ HuggingFaceEmbedding (all-MiniLM-L6-v2, 384-dim)
→ FAISS IndexFlatL2
→ Persisted to data/index/supply_chain.faiss
Knowledge Base Documents
Five supply chain documents power the pipeline. Three are clean, professional documents; two are deliberately messy to demonstrate handling of real-world data quality:
| Document | Type | Purpose |
|---|---|---|
supplier_sop_acme.md |
Clean | Supplier onboarding SOP with checklist and contacts |
quality_standards.md |
Clean | QC procedures: AQL levels, NCR/CAPA processes |
demand_planning_guide.md |
Clean | Forecast methodology: Holt-Winters, seasonality |
shipping_manifest_q1.md |
Messy | 6 date formats, typos, missing fields, abbreviations |
quality_inspection_report.md |
Messy | Mixed temperature units, missing attachments, conflicting data |
Embedding Analysis
scripts/embedding_analysis.py uses PyTorch directly (not through sentence-transformers) to validate that the embedding model properly separates supply chain concepts:
uv run python scripts/embedding_analysis.py
This demonstrates direct PyTorch usage: torch.no_grad(), manual BERT mean pooling with attention masks, F.normalize(), cosine similarity via torch.mm(), and GPU/CPU device management. The script reports model architecture details (22.7M parameters, 6 layers, 12 attention heads) and a concept similarity matrix showing that supply chain domains cluster tightly while off-topic queries are well-separated (max cross-similarity < 0.11).
Prompt Engineering
All agent prompts are versioned YAML templates in prompts/, loaded at runtime by src/prompts/loader.py. A shared guardrails file (_guardrails.yaml) is merged into every agent's system prompt via the {guardrails} placeholder.
prompts/
router/v1.yaml # Intent classification prompt
demand/v1.yaml # Demand agent system prompt
inventory/v1.yaml # Inventory agent system prompt
supplier/v1.yaml # Supplier agent system prompt
_guardrails.yaml # Shared safety rules (merged into all agents)
Guardrails
Six rules are injected into every agent's system prompt:
- No system prompt disclosure — never reveal internal prompts, tool names, or implementation details
- No data fabrication — only report what tools and documents return
- Scope enforcement — politely decline questions outside supply chain operations
- PII protection — never include personally identifiable information in responses
- Citation requirement — always cite document sources when using knowledge base content
- Error transparency — report tool errors clearly rather than guessing
These are tested by 22 guardrails tests: 17 structural tests verify all rules are loaded and embedded in every agent prompt (including regression guards for YAML brace-escape bugs), and 5 adversarial tests exercise prompt injection, off-topic queries, system prompt extraction, PII extraction, and fabrication attempts through the full graph pipeline. A further 5 integration stubs exercise the same scenarios against a real LLM and are deselected from CI until the GPU host is online.
Observability (Langfuse)
The system integrates self-hosted Langfuse (v3) for full LLM observability. Every query generates a multi-step trace that captures:
- Router decision: which agent was selected and why
- Tool calls: function name, arguments, results, and latency
- LLM invocations: input/output tokens, model, temperature
- RAG retrievals: query embedding, retrieved chunks, similarity scores
- End-to-end latency: total time from query to response
Trace URLs are returned in every API response (trace_url field) and displayed as clickable links in the Streamlit UI.
The Langfuse stack runs as 6 Docker services: web dashboard, background worker, PostgreSQL (metadata), ClickHouse (analytics), Redis (cache), and MinIO (S3-compatible event store — required since Langfuse v3 for durable event ingestion).
Self-hosting caveat (current): Langfuse v3.169 with the Python SDK v4.2 has an open server-side OTLP-export mismatch — traces land in the app but the collector returns 500 on span batches. The stack spins up cleanly (all services healthy, admin UI reachable, project seeded) and the Python callback auth-checks OK; wiring the Langfuse-native OpenTelemetry collector remains as a follow-up. In the meantime the FastAPI responses still surface the per-query intent, tool calls, and citations inline.
LLM Provider Support
The LLM is pluggable via the LLM_PROVIDER environment variable and the get_llm() factory in src/agents/__init__.py:
| Provider | Model | Use Case |
|---|---|---|
ollama (default) |
qwen2.5:3b |
Demo + local inference — fits 1050 Ti-class GPUs (≈1.6 GB VRAM), ~27 s per query |
ollama (security mode) |
gemma4:e2b |
Higher injection-resistance — set OLLAMA_MODEL=gemma4:e2b, ~80 s per query, 0 leak markers vs qwen's 3 |
ollama (legacy quality) |
llama3.1:8b |
Opt-in via OLLAMA_MODEL=llama3.1:8b, ~58 s per query — kept for historical comparison |
anthropic |
claude-sonnet-4-20250514 | High-quality cloud fallback |
openai |
gpt-4o-mini | Alternative cloud provider |
mock |
deterministic | CI testing — no GPU or API keys needed |
The mock provider returns canned responses based on prompt keyword matching, enabling the full test suite (83 tests) to run in CI without any external dependencies.
Model benchmark
A canonical query set (one query per agent domain × three adversarial probes) runs against every candidate model through the full LangGraph pipeline. Latest run on the 1050 Ti demo box:
| Model | Avg latency | Intent accuracy | Citations | Injection leak | Off-topic refusal |
|---|---|---|---|---|---|
qwen2.5:3b |
26.8 s | 3/3 | 3 | 3 markers (full prompt verbatim) | clean decline |
gemma4:e2b |
80.0 s | 3/3 | 3 | 0 markers (clean refusal) | failed — wrote the sonnet |
qwen2.5:3b remains the demo default: ~3× faster, GPU-resident on the 1050 Ti, clean
off-topic decline. gemma4:e2b is retained as an opt-in security mode — a materially
better injection defence at the cost of 3× latency and one scope-compliance failure
(it answered the off-topic sonnet prompt). Injection resistance is not deterministic:
qwen2.5:3b leaked 2 markers on 2026-04-18 and 3 today on the same prompt — a single
run is a lower bound on the attack surface, not an upper bound. Production workloads
still need an output-layer guardrail (classifier or regex filter) regardless of model.
Prior benchmark (qwen2.5:3b vs llama3.1:8b) is archived at
docs/benchmarks/benchmark_20260418T032354.md.
Full current report: docs/benchmarks/latest.md. Rerun with:
CUDA_VISIBLE_DEVICES="" uv run python scripts/benchmark_models.py \
--models qwen2.5:3b gemma4:e2b
Hardware note: The 1050 Ti requires Ollama's upstream binary (
curl -fsSL https://ollama.com/install.sh | sh) — CachyOS's packagedollama-cuda 0.21.0dropped compute capability 6.1 (Pascal) from its compile targets and crashes on model load. The upstream build ships with broader GPU arch support per Ollama's official GPU doc. Full reproduction steps + diagnosis indocs/ollama-pascal-setup.md.
Docker Topology (8 Services)
+-------------+ +-------------+
| Streamlit | | App |--> Ollama (LAN GPU)
| (Chat UI) |->| (FastAPI) |
+-------------+ +------+------+
|
+---------+-------+-------+
v v
+-----------+ +--------------------------+
| Redis | | Langfuse Stack |
| (checker | | +------+ +--------+ |
| + state) | | | web | | worker | |
+-----------+ | +--+---+ +---+----+ |
| +--+----+ +--+--------+ |
| |postgres| |clickhouse| |
| +--------+ +----------+ |
| +--------+ |
| | redis | |
| +--------+ |
+--------------------------+
All services have health checks. The Dockerfile uses a multi-stage build (builder with uv sync --frozen → slim runtime with curl for health checks) for minimal image size.
Azure Deployment (IaC)
Bicep templates in infra/bicep/ map the full 8-service topology to Azure Container Apps:
| Module | Resources |
|---|---|
container-app-environment.bicep |
Log Analytics Workspace + Container Apps Environment |
container-registry.bicep |
Azure Container Registry (admin disabled) + AcrPull role assignment |
identity.bicep |
User-Assigned Managed Identity (shared by app + streamlit for ACR pulls) |
redis.bicep |
Redis container (internal, TCP ingress) |
langfuse.bicep |
5 Langfuse services (web, worker, PostgreSQL, ClickHouse, Redis) |
app-services.bicep |
FastAPI (autoscale 1–3 replicas) + Streamlit (external ingress) |
All secrets (Postgres password, NextAuth secret, encryption salt, Langfuse project keys, admin password) are declared as @secure() parameters with no defaults, wired into Container App secrets[] slots, and passed through a gitignored .bicepparam file (or Azure Key Vault reference) — nothing sensitive lives in templates or the command line. CORS is an explicit allow-list, not '*'. The app and streamlit Container Apps pull from ACR using a User-Assigned Managed Identity with the AcrPull role; ACR admin credentials are disabled, so there are no long-lived registry secrets in source or state files.
# One-time: copy example params and fill in generated secrets
cp infra/bicep/main.bicepparam.example infra/bicep/main.bicepparam
# (edit main.bicepparam — see infra/azure-deploy.md for generator commands)
# Local compile check (no Azure calls)
bicep build infra/bicep/main.bicep --stdout > /dev/null
# Validate + deploy against a subscription
az deployment group validate \
--resource-group supply-chain-agents-rg \
--template-file infra/bicep/main.bicep \
--parameters infra/bicep/main.bicepparam
az deployment group create \
--resource-group supply-chain-agents-rg \
--template-file infra/bicep/main.bicep \
--parameters infra/bicep/main.bicepparam
CI runs bicep build on every push (see .github/workflows/ci.yml → validate-infra job) so template regressions are caught before they reach a deploy attempt.
See infra/azure-deploy.md for the full step-by-step deployment guide, including ACR image push, Key Vault secret wiring, cost estimate (~$22–40/month), and troubleshooting.
Tech Stack
| Technology | Role | Requirement Mapping |
|---|---|---|
| LangGraph (v0.4+) | Multi-agent orchestration with typed state | Multi-agent systems, agentic workflows |
| LlamaIndex | RAG pipeline: ingest, chunk, retrieve | NLP/RAG solutions, LlamaIndex |
| FAISS | Vector similarity search (CPU, IndexFlatL2) | FAISS, vectorization |
| PyTorch | Direct tensor ops, mean pooling, device management | BERT, PyTorch |
| sentence-transformers | BERT-based embeddings (all-MiniLM-L6-v2, 384-dim) | BERT, PyTorch |
| FastAPI | REST API with SSE streaming and approval flow | REST API services, productionized APIs |
| Streamlit | Chat UI with approval workflow and metadata display | Frontend/UX |
| Docker Compose | 8-service orchestration with health checks | Docker, Kubernetes |
| Azure Bicep | Container Apps + ACR IaC (5 modules) | Azure Cloud POCs |
| Langfuse (self-hosted) | LLM observability, tracing, prompt management | MLOps |
| Redis | LangGraph checkpointer + approval state persistence | Infrastructure |
| Ollama | Local LLM inference (llama3.1:8b via LAN GPU) | Local AI deployment |
| Pydantic | Typed schemas for state, requests, responses | Data structures/modelling |
| GitHub Actions | CI pipeline: ruff + mypy (strict) + pytest | CI/CD, automated testing |
| Python 3.12 | Entire project (match expressions, type unions) | Mid to Advanced Python |
Quick Start
Docker (full stack)
# Clone the repository
git clone https://codeberg.org/alan090/supply-chain-agents.git
cd supply-chain-agents
# Copy environment config
cp .env.example .env
# Edit .env: set OLLAMA_BASE_URL to your Ollama instance
# Start all 8 services
docker compose up -d
# Wait for health checks to pass (~60s for Langfuse init)
docker compose ps
# Verify health
curl http://localhost:8000/api/v1/health
# Open the chat UI
open http://localhost:8501
# Open the Langfuse dashboard
open http://localhost:3000 # admin@local.dev / admin
Prerequisites
- Docker and Docker Compose v2
- Ollama running with
llama3.1:8bpulled (ollama pull llama3.1:8b) - Ollama accessible from Docker (set
OLLAMA_BASE_URLin.env)
Development Setup
# Install uv (if not already)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create venv and install all dependencies
uv sync --dev
# Run tests (no Ollama needed — uses mock LLM)
uv run pytest tests/ -m "not integration" -v
# Lint + type check
uv run ruff check src/ tests/ streamlit/
uv run mypy -p agents -p api -p rag -p tools -p prompts
# Build FAISS index from docs
uv run python scripts/ingest_docs.py --verbose
# Embedding analysis — direct PyTorch + BERT concept similarity
uv run python scripts/embedding_analysis.py
# Generate synthetic data (already included, but reproducible)
uv run python scripts/generate_data.py
API Reference
All endpoints are prefixed with /api/v1. Full Swagger UI available at http://localhost:8000/docs.
Core Endpoints
| Method | Route | Request Body | Response | Description |
|---|---|---|---|---|
| POST | /query |
QueryRequest |
QueryResponse |
Natural language query routed to the appropriate agent. Returns answer, intent, tool calls, citations, and optional approval ID. |
| GET | /stream/{session_id} |
— | SSE stream | Server-Sent Events stream for real-time agent progress and approval notifications. |
Approval Flow
| Method | Route | Request Body | Response | Description |
|---|---|---|---|---|
| POST | /approvals/{approval_id} |
ApprovalRequest |
ApprovalResponse |
Approve or reject a pending reorder action. Resumes the paused graph via Command(resume=...). |
| GET | /approvals/pending |
— | PendingApprovalsResponse |
Poll for pending approvals. Streamlit polls this every 2 seconds during an active session. |
Direct Agent Endpoints
| Method | Route | Request Body | Response | Description |
|---|---|---|---|---|
| POST | /forecast |
ForecastRequest |
ForecastResponse |
Bypass the router and invoke the demand agent directly. |
| POST | /inventory/check |
InventoryCheckRequest |
InventoryCheckResponse |
Bypass the router and invoke the inventory agent directly. |
| GET | /suppliers |
— | SuppliersResponse |
Return all supplier scorecards. |
Operations
| Method | Route | Description |
|---|---|---|
| POST | /documents/ingest |
Trigger RAG re-ingestion from data/docs/. |
| GET | /health |
Health check for all dependencies (Ollama, Redis, FAISS, Langfuse). Returns ok or degraded. |
Tools
Eight LangChain @tool functions provide structured access to supply chain data. Tools validate inputs and return structured dictionaries — they never fabricate data.
| Tool | Module | Agent | Description |
|---|---|---|---|
query_sales_csv |
tools.sales |
Demand | Aggregate weekly sales data for a SKU |
calculate_forecast |
tools.sales |
Demand | 12-week moving average forecast with confidence intervals |
get_seasonality |
tools.sales |
Demand | Monthly demand patterns, peak/trough identification |
check_stock_levels |
tools.inventory_tools |
Inventory | Current stock status with below-minimum flagging |
calculate_reorder_point |
tools.inventory_tools |
Inventory | Statistical ROP (Z=1.65, 95% service level) with safety stock |
get_supplier_scorecard |
tools.supplier_tools |
Supplier | Full supplier profile + reliability/quality/cost scores |
check_lead_times |
tools.supplier_tools |
Supplier | Lead time statistics: mean, std, on-time rate |
search_supply_chain_docs |
rag.retriever |
Supplier | RAG search across knowledge base with source citations |
Synthetic Data
All data is generated by scripts/generate_data.py with random.seed(42) for full reproducibility. The data models a realistic NALA (North America/Latin America) CPG supply chain:
| File | Description | Records |
|---|---|---|
weekly_sales.csv |
Weekly sales by SKU with seasonal patterns (spring, holiday, gift) | 416 rows (52 weeks x 8 SKUs) |
current_stock.json |
Inventory levels per SKU/warehouse. 2 SKUs deliberately below minimum (Crest, Charmin) to trigger reorder logic | 8 SKUs across 5 distribution centres |
supplier_profiles.json |
Supplier scorecards with reliability, quality, cost scores, and risk factors | 5 suppliers |
lead_times.csv |
Monthly delivery performance with on-time rates ranging from 62% (EcoFiber) to 93% (Acme) | ~200 orders |
SKU Catalogue
| SKU | Product | Category | Seasonal Pattern |
|---|---|---|---|
| SKU-001 | Tide Pods 42ct | Laundry | Spring cleaning peak |
| SKU-002 | Pampers Swaddlers Size 3 | Baby Care | Flat (year-round demand) |
| SKU-003 | Bounty Select-A-Size 8pk | Paper Goods | Holiday spikes |
| SKU-004 | Crest 3D White 4.1oz | Oral Care | New Year's resolution bump |
| SKU-005 | Gillette Fusion5 4ct | Grooming | Father's Day + holiday gift |
| SKU-006 | Dawn Ultra 28oz | Dish Care | Spring + holiday cooking |
| SKU-007 | Charmin Ultra Soft 12pk | Paper Goods | Holiday hoarding + March panic |
| SKU-008 | Olay Regenerist 1.7oz | Skin Care | Valentine's + holiday gift |
Testing
# Unit tests (no external dependencies — mock LLM, no GPU)
uv run pytest tests/ -m "not integration" -v
# Integration tests (requires Ollama running)
uv run pytest tests/ -m integration -v
# With coverage
uv run pytest tests/ --cov=src --cov-report=term-missing
Test Breakdown
| File | Tests | Coverage |
|---|---|---|
test_rag.py |
6 | Ingest clean/messy docs, FAISS persist/reload, citations, empty-result filtering, retriever-as-tool |
test_tools.py |
10 | All 8 tools: happy path + invalid SKU / missing data edge cases |
test_graph.py |
16 | Router classification (3 intents + ambiguous), synthesis, prompt loading, E2E graph flow, supplier RAG wiring regression |
test_api.py |
8 | POST /query valid/invalid, POST /approvals, GET /health up/degraded, POST /documents/ingest accepted + concurrency guard |
test_error_handling.py |
6 | Connection error (type-matched), timeout, no-Redis fallback, router keyword retry, retry exhausted, Langfuse down |
test_guardrails.py |
22 | 17 structural (guardrail rules + PII/citation/brace-escape regression guards across all 4 agents) + 5 adversarial (injection, off-topic, PII extraction, prompt extraction, fabrication) |
test_approvals.py |
15 | ApprovalRecord JSON round-trip, InMemory + Redis stores (fakeredis), multi-replica race, TTL, factory selection |
| Total | 83 | + 5 integration stubs (skipped in CI) |
Test Strategy
- Mock LLM:
src/agents/mock.pyprovides a deterministicMockChatModelthat routes responses by keyword matching. Supports pre-loaded response sequences via themock_llm_with_responsesfixture. Shipped with the package so production code doesn't reach intotests/. - CI-safe: All 83 unit tests run in GitHub Actions without GPU, API keys, or external services (
LLM_PROVIDER=mock,HF_HUB_OFFLINE=1). - Integration tests: Marked
@pytest.mark.integration, require a real Ollama instance. Test actual LLM compliance with guardrails. - Strict type checking:
mypy --strictacross all source packages (23 files, zero errors). - Lint:
ruffwith rules E, F, I, UP, B, SIM. - Infra validation: GitHub Actions runs
bicep build infra/bicep/main.bicepon every push so template regressions are caught before a deploy.
Environment Variables
| Variable | Default | Description |
|---|---|---|
LLM_PROVIDER |
ollama |
LLM backend: ollama, anthropic, openai, mock |
OLLAMA_MODEL |
llama3.1:8b |
Ollama model name |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama API endpoint |
REDIS_URL |
redis://redis:6379 |
Redis connection for LangGraph checkpointer and approval state |
LANGFUSE_PUBLIC_KEY |
pk-lf-local |
Langfuse project public key |
LANGFUSE_SECRET_KEY |
sk-lf-local |
Langfuse project secret key |
LANGFUSE_HOST |
http://langfuse-web:3000 |
Langfuse server URL (internal Docker network) |
LOG_LEVEL |
info |
Application log level |
EMBEDDING_CACHE |
~/.cache/llama_index |
HuggingFace model cache directory |
Project Structure
supply-chain-agents/
pyproject.toml # uv, mypy strict, ruff, pytest config
Dockerfile # Multi-stage: uv builder → slim runtime
docker-compose.yml # 8 services with health checks
.env.example # Environment configuration template
.github/workflows/
ci.yml # GitHub Actions: ruff + mypy + pytest
scripts/
generate_data.py # Seeded synthetic data generator (seed=42)
ingest_docs.py # Build FAISS index from docs
embedding_analysis.py # PyTorch BERT concept similarity analysis
src/
api/
main.py # FastAPI app: 9 endpoints, SSE, health
schemas.py # Pydantic request/response models
agents/
__init__.py # LLM factory: get_llm(provider)
mock.py # Deterministic MockChatModel for CI
state.py # LangGraph typed state (SupplyChainState)
graph.py # StateGraph builder + compiler
nodes/
router.py # Intent classification (JSON + fallback)
demand.py # Demand forecaster + sales tools
inventory.py # Inventory optimizer + interrupt()
supplier.py # Supplier risk + RAG search
synthesis.py # Response merging + citation formatting
tools/
sales.py # query_sales_csv, calculate_forecast, get_seasonality
inventory_tools.py # check_stock_levels, calculate_reorder_point
supplier_tools.py # get_supplier_scorecard, check_lead_times
rag/
ingest.py # LlamaIndex: load → chunk → embed → FAISS
retriever.py # FAISS retriever wrapped as LangChain @tool
prompts/
loader.py # YAML prompt loader + guardrails merger
prompts/
router/v1.yaml # Intent classification prompt
demand/v1.yaml # Demand agent system prompt
inventory/v1.yaml # Inventory agent system prompt
supplier/v1.yaml # Supplier agent system prompt
_guardrails.yaml # Shared safety guardrails (6 rules)
data/
sales/weekly_sales.csv # 52 weeks x 8 SKUs (seeded)
inventory/current_stock.json # 8 SKU stock levels (2 critically low)
suppliers/
supplier_profiles.json # 5 suppliers with scorecards
lead_times.csv # 12 months of delivery performance
docs/
supplier_sop_acme.md # Clean: onboarding SOP
quality_standards.md # Clean: QC procedures (AQL/NCR/CAPA)
demand_planning_guide.md # Clean: Holt-Winters, seasonality
shipping_manifest_q1.md # Messy: 6 date formats, typos
quality_inspection_report.md # Messy: mixed units, missing data
infra/
bicep/
main.bicep # Azure Container Apps orchestrator
main.bicepparam.example # Example parameters file (secrets go here, gitignored)
modules/
container-app-environment.bicep # Log Analytics + Environment
container-registry.bicep # ACR (admin disabled) + AcrPull role assignment
identity.bicep # User-Assigned Managed Identity for ACR pulls
redis.bicep # App Redis container
langfuse.bicep # Langfuse 5-service stack
app-services.bicep # FastAPI + Streamlit containers
azure-deploy.md # Step-by-step Azure deployment guide
streamlit/
app.py # Chat UI with approval flow + metadata
tests/
conftest.py # Shared fixtures (mock LLM, FAISS index)
test_rag.py # 6 RAG pipeline tests
test_tools.py # 10 tool unit tests
test_graph.py # 14 graph tests (router, synthesis, E2E)
test_api.py # 6 API endpoint tests
test_guardrails.py # 18 guardrails tests (structural + adversarial)
test_error_handling.py # 5 resilience tests
Design Decisions
| Decision | Choice | Why |
|---|---|---|
| Agent framework | LangGraph (not AgentExecutor) | Graph-based state machine enables typed state, conditional edges, and interrupt() — the 2026 best practice recommended by the LangChain team |
| RAG framework | LlamaIndex (not LangChain RAG) | Better document ingestion pipeline and node parsing. LangChain handles agent orchestration; LlamaIndex handles retrieval |
| Vector store | FAISS (not Chroma/Pinecone) | Zero external dependencies, battle-tested at scale, CPU-only simplicity |
| Embeddings | all-MiniLM-L6-v2 (384-dim) | Good quality/speed tradeoff for supply chain text. Validated by embedding_analysis.py — supply chain concepts cluster at 0.15–0.32 similarity while off-topic drops below 0.03 |
| LLM | Ollama (local, not cloud API) | Zero API cost, enterprise data sovereignty angle, GPU inference via LAN |
| Observability | Self-hosted Langfuse | Full control over trace data. MLOps capability signal without vendor lock-in |
| Human-in-the-loop | interrupt() conditional |
Only pauses for actionable recommendations (reorder), not informational queries. Production-grade pattern |
| Checkpointer | RedisSaver | Required for interrupt() to work. Persists graph state across API requests |
| Streaming | SSE via astream_events() |
Progressive token streaming for responsive UX. Streamlit uses polling (its re-run model doesn't support SSE consumption), but SSE is retained for non-Streamlit clients |
| Synthetic data | Messy + clean | Real supply chain data is never clean. Including messy docs with typos, inconsistent dates, and mixed units demonstrates data reality awareness |
| Testing | Mock LLM in CI | Deterministic, no GPU/API needed. Integration tests with real Ollama run locally |
| Type checking | mypy strict | Catches bugs early. Every function annotated, all 23 source files clean |
Milestone History
| Milestone | Description | Tests Added |
|---|---|---|
| M2 | Project scaffold, synthetic data, Docker Compose | — |
| M3 | RAG pipeline: LlamaIndex + FAISS + sentence-transformers | +6 |
| M4 | Tools layer: 7 LangChain tools for sales/inventory/supplier | +10 |
| M5 | LangGraph core: router, 3 agents, synthesis, typed state | +14 |
| M6 | FastAPI: 9 endpoints, SSE streaming, approval flow | +11 |
| M7 | Streamlit chat UI, Langfuse trace URL integration | — |
| M8 | Docker health checks (all 8 services), GitHub Actions CI, mypy strict | — |
| M9 | Guardrails tests: structural + adversarial + integration stubs | +18 |
| M10 | Azure Bicep templates (5 modules) + deployment guide | — |
| M11 | Production hardening + demo-ready state. Review fixes: supplier RAG wiring, router prompt brace-leak, @secure() Bicep params + CORS allow-list + CI bicep build, Redis-backed approval store, empirically-tuned RAG threshold, non-blocking /ingest, de-duplicated citations, type-matched exception routing, MockChatModel moved to src/agents/, non-root container + per-service mem_limit, User-Assigned Managed Identity for ACR pulls. Demo polish: 5 real-LLM integration tests (were pytest.skip stubs), FAISS docstore persistence fix (real retriever crashed with KeyError on every hit), Langfuse v3.169 compose hardening (ClickHouse creds, ENCRYPTION_KEY, MinIO S3 event-upload), Dockerfile PYTHONPATH=/app/src + .dockerignore (context 7 GB → 1.8 MB), Streamlit st.rerun() after interrupt(), langfuse.langchain import migration for SDK 4.x, model benchmark script + verdict doc, Playwright-driven demo GIF + Langfuse screenshot |
+29 |
| M12 | Output-layer guardrails: output_guard node between synthesis and END with two-tier validation (regex hard-block for PII/prompt-leak + LLM judge soft-annotate for fabrication/missing-citation), two-layer side-channel scrub (node + API), Langfuse v4 sub-spans via get_client().start_as_current_observation(), Streamlit banner, dedicated _build_judge_model() with hard timeout. Breaking: citations/tool_calls reducer dropped (replace semantics). |
+62 |
License
GPL-3.0

