File size: 16,401 Bytes
e36160c b2f5f60 e36160c b2f5f60 e36160c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 | """
Multi-Agent Autonomous Procurement System
LangGraph + LangChain (Groq / Llama-3.3-70b) + LangSmith
"""
import os
import re
import json
import uuid
from typing import TypedDict, Annotated
from datetime import datetime
from operator import add
# ββ LangSmith must be configured BEFORE any LangChain import ββββββββββββββββββ
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "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"
os.environ["LANGCHAIN_PROJECT"] = "multi_agent_procurement"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
from langchain_groq import ChatGroq
from langchain_core.language_models import BaseLanguageModel
from langchain_core.messages import HumanMessage
# ββ Credentials ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile"
os.environ["GROQ_API_KEY"] = GROQ_API_KEY
# =============================================================================
# 1. STATE
# =============================================================================
class ProcurementState(TypedDict):
raw_input: str
procurement_request: str
vendor_options: list
selected_vendor: dict
budget_limit: float
analysis_approved: bool
human_approved: bool
contract_draft: str
logs: Annotated[list, add]
def _empty_state() -> dict:
return {
"raw_input": "",
"procurement_request": "",
"vendor_options": [],
"selected_vendor": {},
"budget_limit": 5000.0,
"analysis_approved": False,
"human_approved": False,
"contract_draft": "",
"logs": [],
}
# =============================================================================
# 2. VENDOR DATABASE (plain functions β no @tool wrapper)
# =============================================================================
def mock_vendor_search(query: str) -> list:
db = {
"cloud_infrastructure": [
{
"vendor_id": "VENDOR_001",
"name": "CloudTech Solutions",
"service_type": "Cloud Infrastructure",
"price_per_month": 5000,
"capabilities": ["Auto-scaling", "99.99% Uptime SLA", "24/7 Support"],
"reputation_score": 9.2,
"contract_terms": "12-month minimum",
},
{
"vendor_id": "VENDOR_002",
"name": "InfraSpeed Inc",
"service_type": "Cloud Infrastructure",
"price_per_month": 3500,
"capabilities": ["Auto-scaling", "99.9% Uptime SLA", "Business hours support"],
"reputation_score": 8.5,
"contract_terms": "6-month minimum",
},
{
"vendor_id": "VENDOR_003",
"name": "EcoCloud Global",
"service_type": "Cloud Infrastructure",
"price_per_month": 6200,
"capabilities": ["Auto-scaling", "99.999% Uptime SLA", "24/7 Premium Support", "Green energy"],
"reputation_score": 9.5,
"contract_terms": "12-month minimum",
},
],
"software_licensing": [
{
"vendor_id": "VENDOR_004",
"name": "ProSoft Licensing",
"service_type": "Enterprise Software",
"price_per_month": 2000,
"capabilities": ["1000 user licenses", "Cloud deployment", "Annual updates"],
"reputation_score": 8.8,
"contract_terms": "Annual renewal",
},
{
"vendor_id": "VENDOR_005",
"name": "NextGen Software Co",
"service_type": "Enterprise Software",
"price_per_month": 2800,
"capabilities": ["2000 user licenses", "Cloud + On-prem", "Quarterly updates", "Custom integrations"],
"reputation_score": 9.1,
"contract_terms": "Annual renewal",
},
],
}
q = query.lower()
category = "cloud_infrastructure" if any(w in q for w in ["cloud", "server", "infra", "hosting"]) else "software_licensing"
return db[category]
def validate_budget(vendor_price: float, budget_limit: float) -> dict:
variance = budget_limit - vendor_price
within = vendor_price <= budget_limit
pct = round(variance / budget_limit * 100, 2) if budget_limit > 0 else 0
return {
"within_budget": within,
"vendor_price": vendor_price,
"budget_limit": budget_limit,
"variance": variance,
"variance_percentage": pct,
"status": "APPROVED" if within else "REJECTED",
}
# =============================================================================
# 3. LLM HELPER β plain text call, strips markdown fences, returns str
# =============================================================================
def _llm_call(llm: ChatGroq, prompt: str) -> str:
response = llm.invoke([HumanMessage(content=prompt)])
text = response.content.strip()
# Strip ```json ... ``` or ``` ... ```
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
return text.strip()
# =============================================================================
# 4. AGENT NODES
# =============================================================================
class ProcurementAgents:
def __init__(self, llm: ChatGroq):
self.llm = llm
# ββ Agent 0: Prose β Structured ββββββββββββββββββββββββββββββββββββββββββ
def parse_node(self, state: ProcurementState) -> Command:
raw = (state.get("raw_input") or "").strip()
req = (state.get("procurement_request") or "").strip()
ts = datetime.now().strftime("%H:%M:%S")
# If no prose, just pass through whatever structured request was given
if not raw:
log = f"[{ts}] ParseNode: Structured input β skipping prose extraction."
return Command(
update={"logs": [log]},
goto="research_node",
)
log = f"[{ts}] ParseNode: Extracting structured fields from prose..."
prompt = (
"You are a procurement intake assistant. Extract structured procurement details "
"from the user description below.\n\n"
f"User input: {raw}\n\n"
"Reply with ONLY valid JSON, no markdown:\n"
'{\n'
' "procurement_request": "<1-2 sentence professional description>",\n'
' "budget_limit": <number, monthly budget, default 5000 if not mentioned>,\n'
' "confidence": "<high|medium|low>"\n'
'}'
)
try:
content = _llm_call(self.llm, prompt)
parsed = json.loads(content)
req = parsed.get("procurement_request") or raw
budget = float(parsed.get("budget_limit") or state.get("budget_limit") or 5000)
conf = parsed.get("confidence", "medium")
log += f" | Request: '{req[:60]}' | Budget: ${budget:,.0f} | Confidence: {conf}"
except Exception as e:
# Fallback: raw text, keep existing budget
req = raw
budget = float(state.get("budget_limit") or 5000)
log += f" | Parse failed ({e}), using raw input verbatim."
return Command(
update={"procurement_request": req, "budget_limit": budget, "logs": [log]},
goto="research_node",
)
# ββ Agent 1: Research βββββββββββββββββββββββββββββββββββββββββββββββββββββ
def research_node(self, state: ProcurementState) -> Command:
request = (state.get("procurement_request") or "").strip()
ts = datetime.now().strftime("%H:%M:%S")
if not request:
log = f"[{ts}] ResearchNode: No request text β aborting."
return Command(update={"logs": [log]}, goto=END)
vendors = mock_vendor_search(request)
log = f"[{ts}] ResearchNode: Found {len(vendors)} vendor(s) for '{request[:50]}...'"
return Command(
update={"vendor_options": vendors, "logs": [log]},
goto="analysis_node",
)
# ββ Agent 2: Financial Analysis βββββββββββββββββββββββββββββββββββββββββββ
def analysis_node(self, state: ProcurementState) -> Command:
vendors = state.get("vendor_options") or []
budget = float(state.get("budget_limit") or 5000)
ts = datetime.now().strftime("%H:%M:%S")
log = f"[{ts}] AnalysisNode: Evaluating {len(vendors)} vendor(s) vs budget ${budget:,.0f}"
if not vendors:
log += " | No vendors to evaluate."
return Command(update={"analysis_approved": False, "logs": [log]}, goto=END)
prompt = (
f"You are a financial analyst. Budget limit: ${budget}/month.\n"
f"Vendors:\n{json.dumps(vendors, indent=2)}\n\n"
"Pick the BEST vendor whose price_per_month is AT OR BELOW the budget. "
"If none fit, pick the cheapest one.\n"
"Reply with ONLY valid JSON:\n"
'{"recommended_vendor_id": "<vendor_id>", "rationale": "<one sentence>"}'
)
try:
content = _llm_call(self.llm, prompt)
parsed = json.loads(content)
rec_id = parsed.get("recommended_vendor_id", "")
# Find the recommended vendor; fallback to cheapest
selected = min(vendors, key=lambda v: v["price_per_month"])
for v in vendors:
if v["vendor_id"] == rec_id:
selected = v
break
except Exception:
selected = min(vendors, key=lambda v: v["price_per_month"])
result = validate_budget(selected["price_per_month"], budget)
log += f" | Selected: {selected['name']} (${selected['price_per_month']:,}/mo) | {result['status']}"
if result["within_budget"]:
return Command(
update={"selected_vendor": selected, "analysis_approved": True, "logs": [log]},
goto="approval_gate",
)
else:
log += " | All vendors exceed budget β workflow rejected."
return Command(
update={"selected_vendor": selected, "analysis_approved": False, "logs": [log]},
goto=END,
)
# ββ Agent 3: Legal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def legal_node(self, state: ProcurementState) -> Command:
vendor = state.get("selected_vendor") or {}
request = state.get("procurement_request") or ""
ts = datetime.now().strftime("%H:%M:%S")
log = f"[{ts}] LegalNode: Drafting contract for {vendor.get('name', 'Unknown')}"
prompt = (
"Draft a professional PURCHASE AGREEMENT in plain text (no markdown headers).\n\n"
f"Vendor: {vendor.get('name', 'N/A')}\n"
f"Service: {vendor.get('service_type', 'N/A')}\n"
f"Monthly Cost: ${vendor.get('price_per_month', 0):,}\n"
f"Capabilities: {', '.join(vendor.get('capabilities', []))}\n"
f"Contract Terms: {vendor.get('contract_terms', 'Standard')}\n"
f"Procurement Need: {request}\n\n"
"Include: Parties, Services, Pricing & Payment, Term & Termination, "
"Warranties, Confidentiality, Signatures."
)
contract = _llm_call(self.llm, prompt)
log += " | Contract generated successfully."
return Command(
update={"contract_draft": contract, "logs": [log]},
goto=END,
)
# =============================================================================
# 5. APPROVAL GATE & ROUTER
# =============================================================================
def approval_gate(state: ProcurementState) -> dict:
vendor_name = (state.get("selected_vendor") or {}).get("name", "Unknown")
ts = datetime.now().strftime("%H:%M:%S")
log = f"[{ts}] ApprovalGate: Paused β awaiting human decision for '{vendor_name}'"
return {"logs": [log]}
def _approval_router(state: ProcurementState) -> str:
approved = state.get("human_approved")
return "legal_node" if approved else END
# =============================================================================
# 6. GRAPH
# =============================================================================
def build_procurement_graph(llm: ChatGroq) -> tuple:
agents = ProcurementAgents(llm)
graph = StateGraph(ProcurementState)
graph.add_node("parse_node", agents.parse_node)
graph.add_node("research_node", agents.research_node)
graph.add_node("analysis_node", agents.analysis_node)
graph.add_node("approval_gate", approval_gate)
graph.add_node("legal_node", agents.legal_node)
graph.add_edge(START, "parse_node")
# parse_node / research_node / analysis_node route via Command.goto
graph.add_conditional_edges("approval_gate", _approval_router)
graph.add_edge("legal_node", END)
memory = MemorySaver()
compiled = graph.compile(checkpointer=memory, interrupt_before=["legal_node"])
return compiled, memory, agents
# =============================================================================
# 7. EXECUTOR
# =============================================================================
class ProcurementWorkflowExecutor:
def __init__(self, graph, checkpointer):
self.graph = graph
self.checkpointer = checkpointer
# UUID ensures no thread_id collisions across rapid calls
self.thread_id = f"proc_{uuid.uuid4().hex[:12]}"
def _config(self) -> dict:
return {"configurable": {"thread_id": self.thread_id}}
def _safe_state(self) -> dict:
snap = self.graph.get_state(self._config())
if snap is None or snap.values is None:
return _empty_state()
return dict(snap.values)
def start_workflow(self, procurement_request: str, budget_limit: float, raw_input: str = "") -> tuple:
initial = {
**_empty_state(),
"raw_input": raw_input.strip(),
"procurement_request": procurement_request.strip(),
"budget_limit": float(budget_limit),
"logs": [f"Workflow started at {datetime.now().strftime('%H:%M:%S')}"],
}
events = []
for event in self.graph.stream(initial, self._config(), stream_mode="updates"):
events.append(event)
return self._safe_state(), events
def get_state(self) -> dict:
return self._safe_state()
def approve_vendor(self, approval: bool) -> tuple:
self.graph.update_state(self._config(), {"human_approved": approval})
events = []
for event in self.graph.stream(None, self._config(), stream_mode="updates"):
events.append(event)
return self._safe_state(), events
# =============================================================================
# 8. LLM FACTORY
# =============================================================================
def create_llm() -> ChatGroq:
return ChatGroq(
model=GROQ_MODEL,
api_key=GROQ_API_KEY,
temperature=0.3,
max_tokens=4096,
)
|