Upload 13 files
Browse files
app.py
CHANGED
|
@@ -1,290 +1,620 @@
|
|
| 1 |
"""
|
| 2 |
-
Multi-Agent Autonomous Procurement System β FastAPI
|
| 3 |
-
Hugging Face Spaces entry-point
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
POST /workflow/{thread_id}/decide β approve or reject β resume graph β generate contract
|
| 9 |
-
GET /health β liveness probe
|
| 10 |
"""
|
| 11 |
|
| 12 |
import json
|
| 13 |
-
import
|
|
|
|
|
|
|
|
|
|
| 14 |
from contextlib import asynccontextmanager
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
-
from fastapi import FastAPI, HTTPException
|
| 18 |
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
from fastapi.responses import HTMLResponse
|
|
|
|
| 20 |
from pydantic import BaseModel, Field
|
| 21 |
|
| 22 |
-
from procurement_system import
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
_graph = None
|
| 30 |
_memory = None
|
| 31 |
|
| 32 |
-
|
| 33 |
@asynccontextmanager
|
| 34 |
async def lifespan(app: FastAPI):
|
| 35 |
global _graph, _memory
|
| 36 |
-
llm
|
| 37 |
_graph, _memory, _ = build_procurement_graph(llm)
|
| 38 |
-
print("Procurement graph
|
| 39 |
yield
|
| 40 |
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
app = FastAPI(
|
| 43 |
-
title="Multi-Agent Procurement System",
|
| 44 |
-
description=(
|
| 45 |
-
"LangGraph multi-agent workflow: Vendor Research β Financial Analysis β "
|
| 46 |
-
"Human Approval Gate β Legal Contract Generation. "
|
| 47 |
-
"Powered by Groq (Llama-3.3-70b) and traced via LangSmith."
|
| 48 |
-
),
|
| 49 |
-
version="1.0.0",
|
| 50 |
-
lifespan=lifespan,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
app.add_middleware(
|
| 54 |
-
CORSMiddleware,
|
| 55 |
-
allow_origins=["*"],
|
| 56 |
-
allow_methods=["*"],
|
| 57 |
-
allow_headers=["*"],
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
# ββ In-memory executor registry (thread_id β executor) βββββββββββββββββββββββ
|
| 61 |
_sessions: dict[str, ProcurementWorkflowExecutor] = {}
|
|
|
|
| 62 |
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
...,
|
| 69 |
-
example="We need enterprise cloud infrastructure with auto-scaling and high availability SLA",
|
| 70 |
-
)
|
| 71 |
-
budget_limit: float = Field(..., gt=0, example=5500.0)
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
|
|
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
"""Convert LangGraph state (a dict-like StateSnapshot.values) to plain dict."""
|
| 80 |
return {k: v for k, v in dict(state).items()}
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
"node": list(e.keys())[0],
|
| 87 |
-
"updates": {
|
| 88 |
-
k: (v[:500] if isinstance(v, str) else v)
|
| 89 |
-
for k, v in list(e.values())[0].items()
|
| 90 |
-
},
|
| 91 |
-
}
|
| 92 |
-
for e in events
|
| 93 |
-
]
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
@app.get("/health")
|
| 99 |
def health():
|
| 100 |
return {"status": "ok", "graph_ready": _graph is not None}
|
| 101 |
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
@app.post("/workflow/start",
|
| 104 |
-
def start_workflow(body: StartRequest):
|
| 105 |
-
"""
|
| 106 |
-
Runs ResearchNode β AnalysisNode β ApprovalGate.
|
| 107 |
-
Graph pauses before LegalNode and returns the state for human review.
|
| 108 |
-
Returns a thread_id you must pass to /decide.
|
| 109 |
-
"""
|
| 110 |
executor = ProcurementWorkflowExecutor(_graph, _memory)
|
| 111 |
_sessions[executor.thread_id] = executor
|
| 112 |
-
|
| 113 |
state, events = executor.start_workflow(body.procurement_request, body.budget_limit)
|
| 114 |
-
s =
|
| 115 |
-
|
| 116 |
return {
|
| 117 |
-
"thread_id":
|
| 118 |
-
"status":
|
| 119 |
"analysis_approved": s.get("analysis_approved"),
|
| 120 |
-
"selected_vendor":
|
| 121 |
-
"vendor_options":
|
| 122 |
-
"budget_limit":
|
| 123 |
-
"logs":
|
| 124 |
-
"events":
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
If approved, LegalNode runs and returns the generated contract.
|
| 143 |
-
"""
|
| 144 |
-
executor = _sessions.get(thread_id)
|
| 145 |
-
if not executor:
|
| 146 |
-
raise HTTPException(status_code=404, detail=f"Thread '{thread_id}' not found.")
|
| 147 |
-
|
| 148 |
-
state, events = executor.approve_vendor(body.approve)
|
| 149 |
-
s = _serialise_state(state)
|
| 150 |
-
|
| 151 |
-
if body.approve:
|
| 152 |
-
outcome = "contract_generated" if s.get("contract_draft") else "approved_pending_contract"
|
| 153 |
-
else:
|
| 154 |
-
outcome = "rejected_by_human"
|
| 155 |
-
|
| 156 |
return {
|
| 157 |
-
"thread_id":
|
| 158 |
-
"outcome":
|
| 159 |
-
"human_approved":
|
| 160 |
-
"contract_draft":
|
| 161 |
"selected_vendor": s.get("selected_vendor"),
|
| 162 |
-
"logs":
|
| 163 |
-
"events":
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
-
|
| 167 |
-
# ββ Root: interactive API explorer HTML ββββββββββββββββββββββββββββββββββββββ
|
| 168 |
|
| 169 |
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 170 |
def root():
|
| 171 |
-
return """
|
| 172 |
-
<!DOCTYPE html>
|
| 173 |
<html lang="en">
|
| 174 |
<head>
|
| 175 |
-
<meta charset="UTF-8"/>
|
| 176 |
-
<
|
| 177 |
-
<title>Multi-Agent Procurement System</title>
|
| 178 |
<style>
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
</style>
|
| 203 |
</head>
|
| 204 |
<body>
|
| 205 |
-
<header>
|
| 206 |
-
<h1>Multi-Agent Autonomous Procurement System</h1>
|
| 207 |
-
<p>LangGraph Β· LangChain Β· Groq Llama-3.3-70b Β· LangSmith</p>
|
| 208 |
-
<div style="margin-top:1rem;display:flex;gap:.5rem;justify-content:center;flex-wrap:wrap">
|
| 209 |
-
<span class="badge badge-groq">Groq llama-3.3-70b-versatile</span>
|
| 210 |
-
<span class="badge badge-lg">LangGraph 0.2</span>
|
| 211 |
-
<span class="badge badge-ls">LangSmith Traced</span>
|
| 212 |
-
</div>
|
| 213 |
-
</header>
|
| 214 |
-
|
| 215 |
-
<div class="container">
|
| 216 |
-
|
| 217 |
-
<div class="card">
|
| 218 |
-
<h2>Agent Workflow</h2>
|
| 219 |
-
<div class="flow">
|
| 220 |
-
<div class="step active">Research Agent<br/><small>Vendor Discovery</small></div>
|
| 221 |
-
<div class="arrow">β</div>
|
| 222 |
-
<div class="step active">Analysis Agent<br/><small>Budget Validation</small></div>
|
| 223 |
-
<div class="arrow">β</div>
|
| 224 |
-
<div class="step active">Approval Gate<br/><small>Human-in-the-Loop</small></div>
|
| 225 |
-
<div class="arrow">β</div>
|
| 226 |
-
<div class="step active">Legal Agent<br/><small>Contract Generation</small></div>
|
| 227 |
-
</div>
|
| 228 |
-
<p style="color:#64748b;font-size:.85rem">
|
| 229 |
-
The graph compiles with <code>interrupt_before=["legal_node"]</code> so execution pauses
|
| 230 |
-
after analysis for human review. Call <code>/decide</code> to resume.
|
| 231 |
-
</p>
|
| 232 |
-
</div>
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
<
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
<
|
|
|
|
|
|
|
| 241 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
| 246 |
</div>
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
<
|
| 250 |
-
<span style="color:#64748b;font-size:.8rem">Body: </span>
|
| 251 |
-
<code>{"approve": true}</code>
|
| 252 |
-
<span style="color:#64748b;font-size:.8rem"> β resumes graph, returns contract</span>
|
| 253 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
<div class="endpoint">
|
| 256 |
-
<span class="method get">GET</span><strong>/health</strong><br/>
|
| 257 |
-
<span style="color:#64748b;font-size:.8rem">Liveness probe</span>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
-
<
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
<
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
|
|
|
|
|
|
| 282 |
</div>
|
| 283 |
|
| 284 |
-
<
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
</body>
|
| 289 |
-
</html>
|
| 290 |
-
"""
|
|
|
|
| 1 |
"""
|
| 2 |
+
Multi-Agent Autonomous Procurement System β FastAPI + Auth
|
| 3 |
+
Hugging Face Spaces Docker entry-point Β· uvicorn app:app --host 0.0.0.0 --port 7860
|
| 4 |
+
|
| 5 |
+
Auth: POST /auth/login β Bearer JWT (HS256, 2-hour expiry)
|
| 6 |
+
UI: GET / β full SPA (login + dashboard, no framework)
|
| 7 |
+
API: all /workflow/* routes require Authorization: Bearer <token>
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
| 11 |
+
import time
|
| 12 |
+
import hmac
|
| 13 |
+
import hashlib
|
| 14 |
+
import base64
|
| 15 |
from contextlib import asynccontextmanager
|
| 16 |
from typing import Any
|
| 17 |
|
| 18 |
+
from fastapi import FastAPI, HTTPException, Depends, status
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
from fastapi.responses import HTMLResponse
|
| 21 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 22 |
from pydantic import BaseModel, Field
|
| 23 |
|
| 24 |
+
from procurement_system import build_procurement_graph, create_llm, ProcurementWorkflowExecutor
|
| 25 |
+
|
| 26 |
+
# ββ Auth config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
_JWT_SECRET = "procurement-demo-secret-2024"
|
| 28 |
+
_JWT_EXPIRY = 7200 # 2 hours
|
| 29 |
+
|
| 30 |
+
# Dummy user store { username: { password, role, display_name } }
|
| 31 |
+
USERS = {
|
| 32 |
+
"admin": {"password": "admin123", "role": "admin", "name": "Admin User"},
|
| 33 |
+
"buyer": {"password": "buyer123", "role": "buyer", "name": "Sarah (Buyer)"},
|
| 34 |
+
"manager": {"password": "manager123", "role": "manager", "name": "John (Manager)"},
|
| 35 |
+
"analyst": {"password": "analyst123", "role": "analyst", "name": "Priya (Analyst)"},
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# ββ Preset test scenarios exposed to the UI ββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
TEST_CASES = [
|
| 40 |
+
{
|
| 41 |
+
"id": "tc1",
|
| 42 |
+
"label": "β
Cloud β within budget",
|
| 43 |
+
"request": "We need enterprise cloud infrastructure with auto-scaling and 99.99% uptime SLA for our data pipeline.",
|
| 44 |
+
"budget": 5500,
|
| 45 |
+
"expected": "Analysis APPROVED β awaiting human approval",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "tc2",
|
| 49 |
+
"label": "β Cloud β budget too low",
|
| 50 |
+
"request": "We need enterprise cloud infrastructure with auto-scaling and high availability SLA.",
|
| 51 |
+
"budget": 2000,
|
| 52 |
+
"expected": "Analysis REJECTED β all vendors exceed budget",
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"id": "tc3",
|
| 56 |
+
"label": "β
Software β within budget",
|
| 57 |
+
"request": "We need enterprise software licensing for 1500 users with cloud deployment.",
|
| 58 |
+
"budget": 3000,
|
| 59 |
+
"expected": "Analysis APPROVED β awaiting human approval",
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"id": "tc4",
|
| 63 |
+
"label": "β Software β budget too low",
|
| 64 |
+
"request": "We need software licensing with custom integrations and quarterly updates.",
|
| 65 |
+
"budget": 1500,
|
| 66 |
+
"expected": "Analysis REJECTED β all vendors exceed budget",
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"id": "tc5",
|
| 70 |
+
"label": "β
Cloud β tight budget (cheapest vendor)",
|
| 71 |
+
"request": "We need basic cloud infrastructure with auto-scaling for our startup workloads.",
|
| 72 |
+
"budget": 3500,
|
| 73 |
+
"expected": "Analysis APPROVED (cheapest vendor selected)",
|
| 74 |
+
},
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
# ββ Graph singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
_graph = None
|
| 79 |
_memory = None
|
| 80 |
|
|
|
|
| 81 |
@asynccontextmanager
|
| 82 |
async def lifespan(app: FastAPI):
|
| 83 |
global _graph, _memory
|
| 84 |
+
llm = create_llm()
|
| 85 |
_graph, _memory, _ = build_procurement_graph(llm)
|
| 86 |
+
print("Procurement graph ready.")
|
| 87 |
yield
|
| 88 |
|
| 89 |
+
app = FastAPI(title="Procurement System", version="1.0.0", lifespan=lifespan)
|
| 90 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
_sessions: dict[str, ProcurementWorkflowExecutor] = {}
|
| 93 |
+
_bearer = HTTPBearer()
|
| 94 |
|
| 95 |
+
# ββ Minimal JWT (no external lib) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
|
| 97 |
+
def _b64(data: bytes) -> str:
|
| 98 |
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
| 99 |
|
| 100 |
+
def _sign(msg: str) -> str:
|
| 101 |
+
return _b64(hmac.new(_JWT_SECRET.encode(), msg.encode(), hashlib.sha256).digest())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
def create_token(username: str, role: str) -> str:
|
| 104 |
+
header = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
| 105 |
+
payload = _b64(json.dumps({"sub": username, "role": role, "exp": int(time.time()) + _JWT_EXPIRY}).encode())
|
| 106 |
+
return f"{header}.{payload}.{_sign(header + '.' + payload)}"
|
| 107 |
|
| 108 |
+
def verify_token(token: str) -> dict:
|
| 109 |
+
try:
|
| 110 |
+
h, p, sig = token.split(".")
|
| 111 |
+
if _sign(h + "." + p) != sig:
|
| 112 |
+
raise ValueError("bad sig")
|
| 113 |
+
claims = json.loads(base64.urlsafe_b64decode(p + "=="))
|
| 114 |
+
if claims["exp"] < int(time.time()):
|
| 115 |
+
raise ValueError("expired")
|
| 116 |
+
return claims
|
| 117 |
+
except Exception:
|
| 118 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
| 119 |
+
|
| 120 |
+
def current_user(creds: HTTPAuthorizationCredentials = Depends(_bearer)) -> dict:
|
| 121 |
+
return verify_token(creds.credentials)
|
| 122 |
|
| 123 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
|
| 125 |
+
def _ser(state: Any) -> dict:
|
|
|
|
| 126 |
return {k: v for k, v in dict(state).items()}
|
| 127 |
|
| 128 |
+
def _fmt_events(events: list) -> list[dict]:
|
| 129 |
+
out = []
|
| 130 |
+
for e in events:
|
| 131 |
+
node = list(e.keys())[0]
|
| 132 |
+
updates = list(e.values())[0]
|
| 133 |
+
out.append({"node": node, "updates": {k: (v[:400] if isinstance(v, str) else v) for k, v in updates.items()}})
|
| 134 |
+
return out
|
| 135 |
+
|
| 136 |
+
# ββ Auth routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
|
| 138 |
+
class LoginRequest(BaseModel):
|
| 139 |
+
username: str
|
| 140 |
+
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
@app.post("/auth/login", tags=["auth"])
|
| 143 |
+
def login(body: LoginRequest):
|
| 144 |
+
user = USERS.get(body.username)
|
| 145 |
+
if not user or user["password"] != body.password:
|
| 146 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 147 |
+
token = create_token(body.username, user["role"])
|
| 148 |
+
return {"access_token": token, "token_type": "bearer",
|
| 149 |
+
"username": body.username, "name": user["name"], "role": user["role"]}
|
| 150 |
|
| 151 |
+
@app.get("/auth/me", tags=["auth"])
|
| 152 |
+
def me(user: dict = Depends(current_user)):
|
| 153 |
+
u = USERS[user["sub"]]
|
| 154 |
+
return {"username": user["sub"], "name": u["name"], "role": user["role"]}
|
| 155 |
+
|
| 156 |
+
# ββ Workflow routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 157 |
+
|
| 158 |
+
class StartRequest(BaseModel):
|
| 159 |
+
procurement_request: str = Field(..., example="We need enterprise cloud infrastructure")
|
| 160 |
+
budget_limit: float = Field(..., gt=0, example=5500.0)
|
| 161 |
+
|
| 162 |
+
class DecideRequest(BaseModel):
|
| 163 |
+
approve: bool = Field(..., example=True)
|
| 164 |
|
| 165 |
@app.get("/health")
|
| 166 |
def health():
|
| 167 |
return {"status": "ok", "graph_ready": _graph is not None}
|
| 168 |
|
| 169 |
+
@app.get("/testcases", tags=["workflow"])
|
| 170 |
+
def get_test_cases():
|
| 171 |
+
return TEST_CASES
|
| 172 |
|
| 173 |
+
@app.post("/workflow/start", tags=["workflow"])
|
| 174 |
+
def start_workflow(body: StartRequest, user: dict = Depends(current_user)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
executor = ProcurementWorkflowExecutor(_graph, _memory)
|
| 176 |
_sessions[executor.thread_id] = executor
|
|
|
|
| 177 |
state, events = executor.start_workflow(body.procurement_request, body.budget_limit)
|
| 178 |
+
s = _ser(state)
|
|
|
|
| 179 |
return {
|
| 180 |
+
"thread_id": executor.thread_id,
|
| 181 |
+
"status": "awaiting_approval" if s.get("analysis_approved") else "rejected",
|
| 182 |
"analysis_approved": s.get("analysis_approved"),
|
| 183 |
+
"selected_vendor": s.get("selected_vendor"),
|
| 184 |
+
"vendor_options": s.get("vendor_options"),
|
| 185 |
+
"budget_limit": s.get("budget_limit"),
|
| 186 |
+
"logs": s.get("logs"),
|
| 187 |
+
"events": _fmt_events(events),
|
| 188 |
+
"triggered_by": user["sub"],
|
| 189 |
}
|
| 190 |
|
| 191 |
+
@app.get("/workflow/{thread_id}", tags=["workflow"])
|
| 192 |
+
def get_workflow(thread_id: str, user: dict = Depends(current_user)):
|
| 193 |
+
ex = _sessions.get(thread_id)
|
| 194 |
+
if not ex:
|
| 195 |
+
raise HTTPException(404, f"Thread '{thread_id}' not found")
|
| 196 |
+
return {"thread_id": thread_id, "state": _ser(ex.get_state())}
|
| 197 |
+
|
| 198 |
+
@app.post("/workflow/{thread_id}/decide", tags=["workflow"])
|
| 199 |
+
def decide(thread_id: str, body: DecideRequest, user: dict = Depends(current_user)):
|
| 200 |
+
ex = _sessions.get(thread_id)
|
| 201 |
+
if not ex:
|
| 202 |
+
raise HTTPException(404, f"Thread '{thread_id}' not found")
|
| 203 |
+
state, events = ex.approve_vendor(body.approve)
|
| 204 |
+
s = _ser(state)
|
| 205 |
+
outcome = ("contract_generated" if s.get("contract_draft") else "approved_pending_contract") if body.approve else "rejected_by_human"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
return {
|
| 207 |
+
"thread_id": thread_id,
|
| 208 |
+
"outcome": outcome,
|
| 209 |
+
"human_approved": s.get("human_approved"),
|
| 210 |
+
"contract_draft": s.get("contract_draft"),
|
| 211 |
"selected_vendor": s.get("selected_vendor"),
|
| 212 |
+
"logs": s.get("logs"),
|
| 213 |
+
"events": _fmt_events(events),
|
| 214 |
+
"decided_by": user["sub"],
|
| 215 |
}
|
| 216 |
|
| 217 |
+
# ββ SPA ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 218 |
|
| 219 |
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 220 |
def root():
|
| 221 |
+
return HTMLResponse(content=r"""<!DOCTYPE html>
|
|
|
|
| 222 |
<html lang="en">
|
| 223 |
<head>
|
| 224 |
+
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 225 |
+
<title>Procurement AI System</title>
|
|
|
|
| 226 |
<style>
|
| 227 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 228 |
+
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
|
| 229 |
+
/* βββ LOGIN βββ */
|
| 230 |
+
#login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
|
| 231 |
+
.login-box{background:#1e293b;border:1px solid #334155;border-radius:1rem;padding:2.5rem;width:100%;max-width:420px}
|
| 232 |
+
.login-box h1{font-size:1.4rem;font-weight:700;color:#93c5fd;margin-bottom:.4rem}
|
| 233 |
+
.login-box p{color:#64748b;font-size:.85rem;margin-bottom:1.5rem}
|
| 234 |
+
.users-hint{background:#0f172a;border-radius:.5rem;padding:.75rem;margin-bottom:1.5rem;font-size:.78rem;color:#94a3b8;line-height:1.7}
|
| 235 |
+
.users-hint strong{color:#e2e8f0}
|
| 236 |
+
input{width:100%;background:#0f172a;border:1px solid #334155;color:#e2e8f0;border-radius:.5rem;padding:.65rem .9rem;font-size:.9rem;margin-bottom:.75rem;outline:none}
|
| 237 |
+
input:focus{border-color:#3b82f6}
|
| 238 |
+
.btn{width:100%;padding:.7rem;border:none;border-radius:.5rem;font-size:.9rem;font-weight:600;cursor:pointer;transition:.15s}
|
| 239 |
+
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
|
| 240 |
+
.btn-secondary{background:#1e293b;color:#94a3b8;border:1px solid #334155}.btn-secondary:hover{background:#273548}
|
| 241 |
+
.btn-success{background:#16a34a;color:#fff}.btn-success:hover{background:#15803d}
|
| 242 |
+
.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
|
| 243 |
+
.btn-sm{padding:.35rem .8rem;font-size:.8rem;width:auto}
|
| 244 |
+
.err{color:#f87171;font-size:.82rem;margin-top:.5rem}
|
| 245 |
+
/* βββ APP βββ */
|
| 246 |
+
#app-page{display:none;flex-direction:column;min-height:100vh}
|
| 247 |
+
header{background:linear-gradient(135deg,#1e40af,#7c3aed);padding:1rem 1.5rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem}
|
| 248 |
+
header h1{font-size:1.1rem;font-weight:700}
|
| 249 |
+
header p{font-size:.8rem;opacity:.8}
|
| 250 |
+
.user-pill{background:rgba(255,255,255,.15);border-radius:999px;padding:.3rem .8rem;font-size:.8rem;display:flex;align-items:center;gap:.5rem}
|
| 251 |
+
main{flex:1;max-width:1100px;width:100%;margin:0 auto;padding:1.5rem 1rem;display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}
|
| 252 |
+
@media(max-width:700px){main{grid-template-columns:1fr}}
|
| 253 |
+
.card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.25rem}
|
| 254 |
+
.card h2{font-size:.95rem;font-weight:600;color:#93c5fd;margin-bottom:1rem}
|
| 255 |
+
.tc-list{display:flex;flex-direction:column;gap:.5rem}
|
| 256 |
+
.tc-btn{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:.6rem .9rem;cursor:pointer;text-align:left;color:#e2e8f0;font-size:.82rem;transition:.15s}
|
| 257 |
+
.tc-btn:hover{border-color:#3b82f6;background:#172035}
|
| 258 |
+
.tc-btn small{display:block;color:#64748b;margin-top:.2rem}
|
| 259 |
+
label{font-size:.82rem;color:#94a3b8;display:block;margin-bottom:.3rem}
|
| 260 |
+
textarea{width:100%;background:#0f172a;border:1px solid #334155;color:#e2e8f0;border-radius:.5rem;padding:.65rem .9rem;font-size:.85rem;resize:vertical;outline:none;min-height:80px}
|
| 261 |
+
textarea:focus{border-color:#3b82f6}
|
| 262 |
+
.row{display:flex;gap:.75rem;align-items:flex-end;margin-bottom:.75rem}
|
| 263 |
+
.row>div{flex:1}
|
| 264 |
+
.status-badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600}
|
| 265 |
+
.s-waiting{background:#1e3a5f;color:#93c5fd}
|
| 266 |
+
.s-ok{background:#14532d;color:#86efac}
|
| 267 |
+
.s-reject{background:#450a0a;color:#fca5a5}
|
| 268 |
+
.s-contract{background:#3b0764;color:#d8b4fe}
|
| 269 |
+
.vendor-card{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:.75rem;margin-top:.75rem;font-size:.82rem}
|
| 270 |
+
.vendor-card b{color:#93c5fd}
|
| 271 |
+
.log-box{background:#0f172a;border:1px solid #1e293b;border-radius:.5rem;padding:.75rem;font-size:.75rem;font-family:monospace;color:#94a3b8;max-height:160px;overflow-y:auto;margin-top:.75rem;white-space:pre-wrap}
|
| 272 |
+
.contract-box{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:1rem;font-size:.8rem;max-height:340px;overflow-y:auto;margin-top:.75rem;white-space:pre-wrap;line-height:1.6}
|
| 273 |
+
.sep{border:none;border-top:1px solid #334155;margin:.75rem 0}
|
| 274 |
+
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #334155;border-top-color:#3b82f6;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:.4rem}
|
| 275 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 276 |
+
.approve-row{display:flex;gap:.75rem;margin-top:.75rem}
|
| 277 |
+
.flow-steps{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem}
|
| 278 |
+
.fs{background:#0f172a;border:1px solid #475569;border-radius:.4rem;padding:.3rem .6rem;font-size:.75rem;color:#64748b}
|
| 279 |
+
.fs.done{border-color:#16a34a;color:#86efac}
|
| 280 |
+
.fs.active{border-color:#2563eb;color:#93c5fd}
|
| 281 |
+
.fs.error{border-color:#dc2626;color:#fca5a5}
|
| 282 |
+
.arrow{color:#334155;align-self:center;font-size:.8rem}
|
| 283 |
</style>
|
| 284 |
</head>
|
| 285 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
+
<!-- ββββββββββββββββββ LOGIN ββββββββββββββββββ -->
|
| 288 |
+
<div id="login-page">
|
| 289 |
+
<div class="login-box">
|
| 290 |
+
<h1>π’ Procurement AI System</h1>
|
| 291 |
+
<p>Multi-Agent LangGraph Β· Groq Llama-3.3-70b Β· LangSmith</p>
|
| 292 |
+
<div class="users-hint">
|
| 293 |
+
<strong>Demo accounts:</strong><br>
|
| 294 |
+
admin / admin123 Β· buyer / buyer123<br>
|
| 295 |
+
manager / manager123 Β· analyst / analyst123
|
| 296 |
</div>
|
| 297 |
+
<input id="uname" placeholder="Username" onkeydown="if(event.key==='Enter')doLogin()"/>
|
| 298 |
+
<input id="upass" type="password" placeholder="Password" onkeydown="if(event.key==='Enter')doLogin()"/>
|
| 299 |
+
<button class="btn btn-primary" onclick="doLogin()">Sign In</button>
|
| 300 |
+
<div id="login-err" class="err"></div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
|
| 304 |
+
<!-- ββββββββββββββββββ APP ββββββββββββββββββ -->
|
| 305 |
+
<div id="app-page">
|
| 306 |
+
<header>
|
| 307 |
+
<div>
|
| 308 |
+
<h1>Multi-Agent Procurement System</h1>
|
| 309 |
+
<p>LangGraph Β· LangChain Β· Groq llama-3.3-70b Β· LangSmith</p>
|
| 310 |
</div>
|
| 311 |
+
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
|
| 312 |
+
<span class="user-pill">π€ <span id="user-display"></span></span>
|
| 313 |
+
<button class="btn btn-secondary btn-sm" onclick="doLogout()">Sign Out</button>
|
|
|
|
|
|
|
|
|
|
| 314 |
</div>
|
| 315 |
+
</header>
|
| 316 |
+
|
| 317 |
+
<main>
|
| 318 |
+
<!-- LEFT: Input + test cases -->
|
| 319 |
+
<div style="display:flex;flex-direction:column;gap:1.5rem">
|
| 320 |
+
|
| 321 |
+
<div class="card">
|
| 322 |
+
<h2>β‘ Preset Test Cases</h2>
|
| 323 |
+
<div class="tc-list" id="tc-list"></div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div class="card">
|
| 327 |
+
<h2>π Procurement Request</h2>
|
| 328 |
+
<label>Request Description</label>
|
| 329 |
+
<textarea id="req-text" rows="3" placeholder="Describe your procurement needβ¦"></textarea>
|
| 330 |
+
<div class="row">
|
| 331 |
+
<div>
|
| 332 |
+
<label>Budget Limit ($/month)</label>
|
| 333 |
+
<input id="req-budget" type="number" value="5500" min="100" step="100" style="margin-bottom:0"/>
|
| 334 |
+
</div>
|
| 335 |
+
<button class="btn btn-primary btn-sm" onclick="startWorkflow()" id="start-btn">π Run Agents</button>
|
| 336 |
+
</div>
|
| 337 |
+
<div id="start-err" class="err"></div>
|
| 338 |
+
</div>
|
| 339 |
|
|
|
|
|
|
|
|
|
|
| 340 |
</div>
|
| 341 |
|
| 342 |
+
<!-- RIGHT: Results -->
|
| 343 |
+
<div style="display:flex;flex-direction:column;gap:1.5rem">
|
| 344 |
+
|
| 345 |
+
<div class="card">
|
| 346 |
+
<h2>π Agent Pipeline</h2>
|
| 347 |
+
|
| 348 |
+
<div class="flow-steps">
|
| 349 |
+
<div class="fs" id="fs-research">π Research</div>
|
| 350 |
+
<div class="arrow">β</div>
|
| 351 |
+
<div class="fs" id="fs-analysis">π° Analysis</div>
|
| 352 |
+
<div class="arrow">β</div>
|
| 353 |
+
<div class="fs" id="fs-approval">π€ Approval Gate</div>
|
| 354 |
+
<div class="arrow">β</div>
|
| 355 |
+
<div class="fs" id="fs-legal">π Legal</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div id="status-area" style="font-size:.85rem;color:#64748b">
|
| 359 |
+
Run a workflow to see live results.
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
<div id="vendor-area" style="display:none">
|
| 363 |
+
<hr class="sep"/>
|
| 364 |
+
<div style="font-size:.82rem;color:#94a3b8;margin-bottom:.4rem">Selected Vendor</div>
|
| 365 |
+
<div class="vendor-card" id="vendor-card"></div>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
<div id="vendor-options-area" style="display:none;margin-top:.75rem">
|
| 369 |
+
<div style="font-size:.82rem;color:#94a3b8;margin-bottom:.4rem">All Vendor Options</div>
|
| 370 |
+
<div id="vendor-options-list"></div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
<div class="card" id="approval-card" style="display:none">
|
| 375 |
+
<h2>π€ Human Approval Gate</h2>
|
| 376 |
+
<p style="font-size:.83rem;color:#94a3b8">
|
| 377 |
+
The LangGraph graph has <strong style="color:#93c5fd">paused</strong> at
|
| 378 |
+
<code>interrupt_before=["legal_node"]</code>. Review the selected vendor above
|
| 379 |
+
and make your decision.
|
| 380 |
+
</p>
|
| 381 |
+
<div class="approve-row">
|
| 382 |
+
<button class="btn btn-success" onclick="decide(true)" id="approve-btn">β
Approve</button>
|
| 383 |
+
<button class="btn btn-danger" onclick="decide(false)" id="reject-btn">β Reject</button>
|
| 384 |
+
</div>
|
| 385 |
+
<div id="decide-err" class="err"></div>
|
| 386 |
+
</div>
|
| 387 |
+
|
| 388 |
+
<div class="card" id="contract-card" style="display:none">
|
| 389 |
+
<h2>π Generated Contract</h2>
|
| 390 |
+
<div class="contract-box" id="contract-box"></div>
|
| 391 |
+
</div>
|
| 392 |
+
|
| 393 |
+
<div class="card">
|
| 394 |
+
<h2>πͺ΅ Agent Audit Log</h2>
|
| 395 |
+
<div class="log-box" id="log-box">Logs will appear hereβ¦</div>
|
| 396 |
+
</div>
|
| 397 |
|
| 398 |
+
</div>
|
| 399 |
+
</main>
|
| 400 |
</div>
|
| 401 |
|
| 402 |
+
<script>
|
| 403 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 404 |
+
let TOKEN = null, THREAD = null;
|
| 405 |
+
|
| 406 |
+
function apiHeaders() {
|
| 407 |
+
return { "Content-Type": "application/json", "Authorization": "Bearer " + TOKEN };
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// ββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
async function doLogin() {
|
| 412 |
+
const u = document.getElementById("uname").value.trim();
|
| 413 |
+
const p = document.getElementById("upass").value;
|
| 414 |
+
document.getElementById("login-err").textContent = "";
|
| 415 |
+
try {
|
| 416 |
+
const r = await fetch("/auth/login", {
|
| 417 |
+
method: "POST", headers: {"Content-Type":"application/json"},
|
| 418 |
+
body: JSON.stringify({username: u, password: p})
|
| 419 |
+
});
|
| 420 |
+
if (!r.ok) { document.getElementById("login-err").textContent = "Invalid username or password."; return; }
|
| 421 |
+
const d = await r.json();
|
| 422 |
+
TOKEN = d.access_token;
|
| 423 |
+
document.getElementById("user-display").textContent = d.name + " (" + d.role + ")";
|
| 424 |
+
document.getElementById("login-page").style.display = "none";
|
| 425 |
+
document.getElementById("app-page").style.display = "flex";
|
| 426 |
+
loadTestCases();
|
| 427 |
+
} catch(e) {
|
| 428 |
+
document.getElementById("login-err").textContent = "Connection error.";
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
function doLogout() {
|
| 433 |
+
TOKEN = null; THREAD = null;
|
| 434 |
+
document.getElementById("app-page").style.display = "none";
|
| 435 |
+
document.getElementById("login-page").style.display = "flex";
|
| 436 |
+
document.getElementById("upass").value = "";
|
| 437 |
+
resetUI();
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// ββ Test cases βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
+
async function loadTestCases() {
|
| 442 |
+
const r = await fetch("/testcases");
|
| 443 |
+
const cases = await r.json();
|
| 444 |
+
const el = document.getElementById("tc-list");
|
| 445 |
+
el.innerHTML = cases.map(tc => `
|
| 446 |
+
<button class="tc-btn" onclick='fillTestCase(${JSON.stringify(tc)})'>
|
| 447 |
+
${tc.label}
|
| 448 |
+
<small>Budget: $${tc.budget.toLocaleString()} Β· ${tc.expected}</small>
|
| 449 |
+
</button>`).join("");
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function fillTestCase(tc) {
|
| 453 |
+
document.getElementById("req-text").value = tc.request;
|
| 454 |
+
document.getElementById("req-budget").value = tc.budget;
|
| 455 |
+
resetUI();
|
| 456 |
+
appendLog("π Test case loaded: " + tc.label);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// ββ Workflow βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 460 |
+
function resetUI() {
|
| 461 |
+
THREAD = null;
|
| 462 |
+
["fs-research","fs-analysis","fs-approval","fs-legal"].forEach(id => {
|
| 463 |
+
document.getElementById(id).className = "fs";
|
| 464 |
+
});
|
| 465 |
+
document.getElementById("status-area").innerHTML = '<span style="color:#64748b">Ready to run.</span>';
|
| 466 |
+
document.getElementById("vendor-area").style.display = "none";
|
| 467 |
+
document.getElementById("vendor-options-area").style.display = "none";
|
| 468 |
+
document.getElementById("approval-card").style.display = "none";
|
| 469 |
+
document.getElementById("contract-card").style.display = "none";
|
| 470 |
+
document.getElementById("log-box").textContent = "Logs will appear hereβ¦";
|
| 471 |
+
document.getElementById("start-err").textContent = "";
|
| 472 |
+
document.getElementById("decide-err").textContent = "";
|
| 473 |
+
setDecideButtons(true);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
function setStep(id, state) {
|
| 477 |
+
document.getElementById(id).className = "fs " + state;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function appendLog(msg) {
|
| 481 |
+
const el = document.getElementById("log-box");
|
| 482 |
+
if (el.textContent === "Logs will appear hereβ¦") el.textContent = "";
|
| 483 |
+
el.textContent += msg + "\n";
|
| 484 |
+
el.scrollTop = el.scrollHeight;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
function setDecideButtons(enabled) {
|
| 488 |
+
document.getElementById("approve-btn").disabled = !enabled;
|
| 489 |
+
document.getElementById("reject-btn").disabled = !enabled;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function renderVendorCard(v, budget) {
|
| 493 |
+
if (!v || !v.name) return;
|
| 494 |
+
const within = v.price_per_month <= budget;
|
| 495 |
+
document.getElementById("vendor-card").innerHTML = `
|
| 496 |
+
<b>${v.name}</b>
|
| 497 |
+
<span class="status-badge ${within ? 's-ok' : 's-reject'}" style="margin-left:.5rem">
|
| 498 |
+
${within ? "β
Within budget" : "β Over budget"}
|
| 499 |
+
</span><br/>
|
| 500 |
+
<span style="color:#94a3b8">Service:</span> ${v.service_type} |
|
| 501 |
+
<span style="color:#94a3b8">Price:</span> $${v.price_per_month.toLocaleString()}/mo |
|
| 502 |
+
<span style="color:#94a3b8">Score:</span> ${v.reputation_score}/10<br/>
|
| 503 |
+
<span style="color:#94a3b8">Capabilities:</span> ${v.capabilities.join(" Β· ")}<br/>
|
| 504 |
+
<span style="color:#94a3b8">Terms:</span> ${v.contract_terms}
|
| 505 |
+
`;
|
| 506 |
+
document.getElementById("vendor-area").style.display = "block";
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function renderVendorOptions(vendors, budget) {
|
| 510 |
+
if (!vendors || !vendors.length) return;
|
| 511 |
+
const html = vendors.map(v => {
|
| 512 |
+
const w = v.price_per_month <= budget;
|
| 513 |
+
return `<div class="vendor-card" style="margin-bottom:.4rem">
|
| 514 |
+
<b>${v.name}</b>
|
| 515 |
+
<span class="status-badge ${w?'s-ok':'s-reject'}" style="margin-left:.4rem">${w?"β
":"β"} $${v.price_per_month.toLocaleString()}/mo</span>
|
| 516 |
+
Score: ${v.reputation_score}/10 | ${v.capabilities.join(", ")}
|
| 517 |
+
</div>`;
|
| 518 |
+
}).join("");
|
| 519 |
+
document.getElementById("vendor-options-list").innerHTML = html;
|
| 520 |
+
document.getElementById("vendor-options-area").style.display = "block";
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
async function startWorkflow() {
|
| 524 |
+
const req = document.getElementById("req-text").value.trim();
|
| 525 |
+
const budget = parseFloat(document.getElementById("req-budget").value);
|
| 526 |
+
if (!req) { document.getElementById("start-err").textContent = "Enter a procurement request."; return; }
|
| 527 |
+
if (!budget || budget <= 0) { document.getElementById("start-err").textContent = "Enter a valid budget."; return; }
|
| 528 |
+
|
| 529 |
+
resetUI();
|
| 530 |
+
document.getElementById("start-btn").innerHTML = '<span class="spinner"></span>Runningβ¦';
|
| 531 |
+
document.getElementById("start-btn").disabled = true;
|
| 532 |
+
|
| 533 |
+
setStep("fs-research", "active");
|
| 534 |
+
appendLog("π Starting workflowβ¦");
|
| 535 |
+
|
| 536 |
+
try {
|
| 537 |
+
const r = await fetch("/workflow/start", {
|
| 538 |
+
method: "POST", headers: apiHeaders(),
|
| 539 |
+
body: JSON.stringify({procurement_request: req, budget_limit: budget})
|
| 540 |
+
});
|
| 541 |
+
if (r.status === 401) { doLogout(); return; }
|
| 542 |
+
const d = await r.json();
|
| 543 |
+
THREAD = d.thread_id;
|
| 544 |
+
|
| 545 |
+
// Log events
|
| 546 |
+
(d.events || []).forEach(ev => appendLog(" [" + ev.node + "] processed"));
|
| 547 |
+
(d.logs || []).forEach(l => appendLog(l));
|
| 548 |
+
|
| 549 |
+
setStep("fs-research", "done");
|
| 550 |
+
setStep("fs-analysis", d.analysis_approved ? "done" : "error");
|
| 551 |
+
|
| 552 |
+
renderVendorOptions(d.vendor_options, d.budget_limit);
|
| 553 |
+
|
| 554 |
+
if (d.analysis_approved) {
|
| 555 |
+
renderVendorCard(d.selected_vendor, d.budget_limit);
|
| 556 |
+
setStep("fs-approval", "active");
|
| 557 |
+
document.getElementById("status-area").innerHTML =
|
| 558 |
+
'<span class="status-badge s-waiting">βΈ Awaiting Human Approval</span>' +
|
| 559 |
+
'<span style="color:#64748b;font-size:.8rem;margin-left:.5rem">Graph paused at interrupt_before=[\"legal_node\"]</span>';
|
| 560 |
+
document.getElementById("approval-card").style.display = "block";
|
| 561 |
+
} else {
|
| 562 |
+
renderVendorCard(d.selected_vendor, d.budget_limit);
|
| 563 |
+
document.getElementById("status-area").innerHTML =
|
| 564 |
+
'<span class="status-badge s-reject">β Analysis Rejected β all vendors exceed budget</span>';
|
| 565 |
+
appendLog("β Budget exceeded. Workflow terminated.");
|
| 566 |
+
}
|
| 567 |
+
} catch(e) {
|
| 568 |
+
document.getElementById("status-area").innerHTML = '<span style="color:#f87171">Error: ' + e.message + '</span>';
|
| 569 |
+
appendLog("ERROR: " + e.message);
|
| 570 |
+
} finally {
|
| 571 |
+
document.getElementById("start-btn").innerHTML = "π Run Agents";
|
| 572 |
+
document.getElementById("start-btn").disabled = false;
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
async function decide(approve) {
|
| 577 |
+
if (!THREAD) return;
|
| 578 |
+
setDecideButtons(false);
|
| 579 |
+
document.getElementById("decide-err").textContent = "";
|
| 580 |
+
appendLog((approve ? "β
Human APPROVED" : "β Human REJECTED") + " β resuming graphβ¦");
|
| 581 |
+
|
| 582 |
+
try {
|
| 583 |
+
const r = await fetch("/workflow/" + THREAD + "/decide", {
|
| 584 |
+
method: "POST", headers: apiHeaders(),
|
| 585 |
+
body: JSON.stringify({approve})
|
| 586 |
+
});
|
| 587 |
+
if (r.status === 401) { doLogout(); return; }
|
| 588 |
+
const d = await r.json();
|
| 589 |
+
|
| 590 |
+
(d.events || []).forEach(ev => appendLog(" [" + ev.node + "] processed"));
|
| 591 |
+
(d.logs || []).forEach(l => appendLog(l));
|
| 592 |
+
|
| 593 |
+
document.getElementById("approval-card").style.display = "none";
|
| 594 |
+
|
| 595 |
+
if (approve && d.contract_draft) {
|
| 596 |
+
setStep("fs-approval", "done");
|
| 597 |
+
setStep("fs-legal", "done");
|
| 598 |
+
document.getElementById("status-area").innerHTML =
|
| 599 |
+
'<span class="status-badge s-contract">β
Contract Generated</span>';
|
| 600 |
+
document.getElementById("contract-box").textContent = d.contract_draft;
|
| 601 |
+
document.getElementById("contract-card").style.display = "block";
|
| 602 |
+
appendLog("π Contract ready.");
|
| 603 |
+
} else if (approve) {
|
| 604 |
+
setStep("fs-approval", "done");
|
| 605 |
+
document.getElementById("status-area").innerHTML =
|
| 606 |
+
'<span class="status-badge s-ok">Approved β contract pending</span>';
|
| 607 |
+
} else {
|
| 608 |
+
setStep("fs-approval", "error");
|
| 609 |
+
document.getElementById("status-area").innerHTML =
|
| 610 |
+
'<span class="status-badge s-reject">π« Rejected by Human Reviewer</span>';
|
| 611 |
+
appendLog("π« Workflow rejected.");
|
| 612 |
+
}
|
| 613 |
+
} catch(e) {
|
| 614 |
+
document.getElementById("decide-err").textContent = "Error: " + e.message;
|
| 615 |
+
setDecideButtons(true);
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
</script>
|
| 619 |
</body>
|
| 620 |
+
</html>""")
|
|
|