Aigenthix commited on
Commit
ad86087
Β·
verified Β·
1 Parent(s): 53f0f8c

Upload 13 files

Browse files
Files changed (1) hide show
  1. app.py +548 -218
app.py CHANGED
@@ -1,290 +1,620 @@
1
  """
2
- Multi-Agent Autonomous Procurement System β€” FastAPI Backend
3
- Hugging Face Spaces entry-point (Docker SDK space)
4
-
5
- Endpoints:
6
- POST /workflow/start – launch research + analysis, pause at approval gate
7
- GET /workflow/{thread_id} – inspect current state
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 uuid
 
 
 
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
- build_procurement_graph,
24
- create_llm,
25
- ProcurementWorkflowExecutor,
26
- )
27
-
28
- # ── App-level singletons (created once at startup) ───────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  _graph = None
30
  _memory = None
31
 
32
-
33
  @asynccontextmanager
34
  async def lifespan(app: FastAPI):
35
  global _graph, _memory
36
- llm = create_llm()
37
  _graph, _memory, _ = build_procurement_graph(llm)
38
- print("Procurement graph compiled and ready.")
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
- # ── Request / Response models ─────────────────────────────────────────────────
 
65
 
66
- class StartRequest(BaseModel):
67
- procurement_request: str = Field(
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
- class DecideRequest(BaseModel):
75
- approve: bool = Field(..., example=True)
 
 
 
 
 
 
 
 
 
 
 
 
76
 
 
77
 
78
- def _serialise_state(state: Any) -> dict:
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
- def _format_events(events: list) -> list[dict]:
84
- return [
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
- # ── Routes ────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
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", summary="Start procurement workflow")
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 = _serialise_state(state)
115
-
116
  return {
117
- "thread_id": executor.thread_id,
118
- "status": "awaiting_approval" if s.get("analysis_approved") else "rejected",
119
  "analysis_approved": s.get("analysis_approved"),
120
- "selected_vendor": s.get("selected_vendor"),
121
- "vendor_options": s.get("vendor_options"),
122
- "budget_limit": s.get("budget_limit"),
123
- "logs": s.get("logs"),
124
- "events": _format_events(events),
 
125
  }
126
 
127
-
128
- @app.get("/workflow/{thread_id}", summary="Inspect workflow state")
129
- def get_workflow(thread_id: str):
130
- """Return the current checkpointed state for the given thread."""
131
- executor = _sessions.get(thread_id)
132
- if not executor:
133
- raise HTTPException(status_code=404, detail=f"Thread '{thread_id}' not found.")
134
- s = _serialise_state(executor.get_state())
135
- return {"thread_id": thread_id, "state": s}
136
-
137
-
138
- @app.post("/workflow/{thread_id}/decide", summary="Human approval decision")
139
- def decide(thread_id: str, body: DecideRequest):
140
- """
141
- Inject the human decision into the checkpointed state, then resume the graph.
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": thread_id,
158
- "outcome": outcome,
159
- "human_approved": s.get("human_approved"),
160
- "contract_draft": s.get("contract_draft"),
161
  "selected_vendor": s.get("selected_vendor"),
162
- "logs": s.get("logs"),
163
- "events": _format_events(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
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
177
- <title>Multi-Agent Procurement System</title>
178
  <style>
179
- *{box-sizing:border-box;margin:0;padding:0}
180
- body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
181
- header{background:linear-gradient(135deg,#1e40af,#7c3aed);padding:2rem;text-align:center}
182
- header h1{font-size:1.8rem;font-weight:700;margin-bottom:.5rem}
183
- header p{opacity:.85;font-size:.95rem}
184
- .container{max-width:900px;margin:2rem auto;padding:0 1rem}
185
- .card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.5rem;margin-bottom:1.5rem}
186
- .card h2{font-size:1.1rem;font-weight:600;color:#93c5fd;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}
187
- .flow{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}
188
- .step{background:#0f172a;border:1px solid #475569;border-radius:.5rem;padding:.5rem 1rem;font-size:.85rem;color:#94a3b8}
189
- .step.active{border-color:#3b82f6;color:#93c5fd}
190
- .arrow{color:#475569;align-self:center}
191
- .endpoint{background:#0f172a;border-left:3px solid #3b82f6;padding:.75rem 1rem;border-radius:.25rem;margin:.5rem 0;font-family:monospace;font-size:.85rem}
192
- .method{display:inline-block;padding:.15rem .5rem;border-radius:.25rem;font-weight:600;font-size:.8rem;margin-right:.5rem}
193
- .post{background:#166534;color:#86efac}
194
- .get{background:#1e3a5f;color:#93c5fd}
195
- .badge{display:inline-block;padding:.2rem .6rem;border-radius:9999px;font-size:.75rem;font-weight:600}
196
- .badge-groq{background:#4f46e5;color:#fff}
197
- .badge-lg{background:#065f46;color:#6ee7b7}
198
- .badge-ls{background:#7c2d12;color:#fdba74}
199
- footer{text-align:center;padding:2rem;color:#475569;font-size:.85rem}
200
- a{color:#60a5fa;text-decoration:none}
201
- a:hover{text-decoration:underline}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  </style>
203
  </head>
204
  <body>
205
- <header>
206
- <h1>Multi-Agent Autonomous Procurement System</h1>
207
- <p>LangGraph &nbsp;Β·&nbsp; LangChain &nbsp;Β·&nbsp; Groq Llama-3.3-70b &nbsp;Β·&nbsp; 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
- <div class="card">
235
- <h2>API Endpoints</h2>
236
-
237
- <div class="endpoint">
238
- <span class="method post">POST</span><strong>/workflow/start</strong><br/>
239
- <span style="color:#64748b;font-size:.8rem">Body: </span>
240
- <code>{"procurement_request": "...", "budget_limit": 5500}</code>
 
 
241
  </div>
 
 
 
 
 
 
242
 
243
- <div class="endpoint">
244
- <span class="method get">GET</span><strong>/workflow/{thread_id}</strong><br/>
245
- <span style="color:#64748b;font-size:.8rem">Inspect checkpointed state at interruption point</span>
 
 
 
246
  </div>
247
-
248
- <div class="endpoint">
249
- <span class="method post">POST</span><strong>/workflow/{thread_id}/decide</strong><br/>
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
- <p style="margin-top:1rem">
261
- <a href="/docs">Interactive Swagger UI β†’</a>
262
- &nbsp;&nbsp;
263
- <a href="/redoc">ReDoc β†’</a>
264
- </p>
265
- </div>
266
-
267
- <div class="card">
268
- <h2>Quick-start (curl)</h2>
269
- <pre style="background:#0f172a;padding:1rem;border-radius:.5rem;font-size:.8rem;overflow-x:auto;color:#94a3b8">
270
- # 1. Start workflow
271
- curl -X POST http://localhost:7860/workflow/start \\
272
- -H "Content-Type: application/json" \\
273
- -d '{"procurement_request":"cloud infrastructure","budget_limit":5500}'
274
-
275
- # 2. Approve (replace &lt;thread_id&gt; from step 1 response)
276
- curl -X POST http://localhost:7860/workflow/&lt;thread_id&gt;/decide \\
277
- -H "Content-Type: application/json" \\
278
- -d '{"approve":true}'
279
- </pre>
280
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
 
 
282
  </div>
283
 
284
- <footer>
285
- LangSmith project: <code>multi_agent_procurement</code> &nbsp;|&nbsp;
286
- <a href="/docs">Swagger UI</a>
287
- </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &nbsp;Β·&nbsp; buyer / buyer123<br>
295
+ manager / manager123 &nbsp;Β·&nbsp; 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()} &nbsp;Β·&nbsp; ${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} &nbsp;|&nbsp;
501
+ <span style="color:#94a3b8">Price:</span> $${v.price_per_month.toLocaleString()}/mo &nbsp;|&nbsp;
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
+ &nbsp; Score: ${v.reputation_score}/10 &nbsp;|&nbsp; ${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>""")