| """ |
| 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 |
|
|
| |
| 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 |
|
|
| |
| GROQ_API_KEY = "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" |
| GROQ_MODEL = "llama-3.3-70b-versatile" |
| os.environ["GROQ_API_KEY"] = GROQ_API_KEY |
|
|
|
|
| |
| |
| |
|
|
| 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": [], |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def _llm_call(llm: ChatGroq, prompt: str) -> str: |
| response = llm.invoke([HumanMessage(content=prompt)]) |
| text = response.content.strip() |
| |
| text = re.sub(r"^```[a-zA-Z]*\n?", "", text) |
| text = re.sub(r"\n?```$", "", text) |
| return text.strip() |
|
|
|
|
| |
| |
| |
|
|
| class ProcurementAgents: |
| def __init__(self, llm: ChatGroq): |
| self.llm = llm |
|
|
| |
| 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 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: |
| |
| 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", |
| ) |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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", "") |
| |
| 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, |
| ) |
|
|
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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") |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| class ProcurementWorkflowExecutor: |
| def __init__(self, graph, checkpointer): |
| self.graph = graph |
| self.checkpointer = checkpointer |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| def create_llm() -> ChatGroq: |
| return ChatGroq( |
| model=GROQ_MODEL, |
| api_key=GROQ_API_KEY, |
| temperature=0.3, |
| max_tokens=4096, |
| ) |
|
|