| """ |
| Multi-Agent Autonomous Procurement System β FastAPI + Auth |
| Hugging Face Spaces Docker entry-point Β· uvicorn app:app --host 0.0.0.0 --port 7860 |
| |
| Auth: POST /auth/login β Bearer JWT (HS256, 2-hour expiry) |
| UI: GET / β full SPA (login + dashboard, no framework) |
| API: all /workflow/* routes require Authorization: Bearer <token> |
| """ |
|
|
| import json |
| import time |
| import hmac |
| import hashlib |
| import base64 |
| import traceback |
| from contextlib import asynccontextmanager |
| from typing import Any |
|
|
| from fastapi import FastAPI, HTTPException, Depends, Request, status |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials |
| from pydantic import BaseModel, Field |
|
|
| from procurement_system import build_procurement_graph, create_llm, ProcurementWorkflowExecutor |
|
|
| |
| _JWT_SECRET = "procurement-demo-secret-2024" |
| _JWT_EXPIRY = 7200 |
|
|
| |
| USERS = { |
| "admin": {"password": "admin123", "role": "admin", "name": "Admin User"}, |
| "buyer": {"password": "buyer123", "role": "buyer", "name": "Sarah (Buyer)"}, |
| "manager": {"password": "manager123", "role": "manager", "name": "John (Manager)"}, |
| "analyst": {"password": "analyst123", "role": "analyst", "name": "Priya (Analyst)"}, |
| } |
|
|
| |
| TEST_CASES = [ |
| { |
| "id": "tc1", |
| "label": "β
Cloud β within budget", |
| "request": "We need enterprise cloud infrastructure with auto-scaling and 99.99% uptime SLA for our data pipeline.", |
| "budget": 5500, |
| "expected": "Analysis APPROVED β awaiting human approval", |
| }, |
| { |
| "id": "tc2", |
| "label": "β Cloud β budget too low", |
| "request": "We need enterprise cloud infrastructure with auto-scaling and high availability SLA.", |
| "budget": 2000, |
| "expected": "Analysis REJECTED β all vendors exceed budget", |
| }, |
| { |
| "id": "tc3", |
| "label": "β
Software β within budget", |
| "request": "We need enterprise software licensing for 1500 users with cloud deployment.", |
| "budget": 3000, |
| "expected": "Analysis APPROVED β awaiting human approval", |
| }, |
| { |
| "id": "tc4", |
| "label": "β Software β budget too low", |
| "request": "We need software licensing with custom integrations and quarterly updates.", |
| "budget": 1500, |
| "expected": "Analysis REJECTED β all vendors exceed budget", |
| }, |
| { |
| "id": "tc5", |
| "label": "β
Cloud β tight budget (cheapest vendor)", |
| "request": "We need basic cloud infrastructure with auto-scaling for our startup workloads.", |
| "budget": 3500, |
| "expected": "Analysis APPROVED (cheapest vendor selected)", |
| }, |
| ] |
|
|
| |
| _graph = None |
| _memory = None |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| global _graph, _memory |
| llm = create_llm() |
| _graph, _memory, _ = build_procurement_graph(llm) |
| print("Procurement graph ready.") |
| yield |
|
|
| app = FastAPI(title="Procurement System", version="1.0.0", lifespan=lifespan) |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) |
|
|
| @app.exception_handler(Exception) |
| async def global_exception_handler(request: Request, exc: Exception): |
| tb = traceback.format_exc() |
| print(f"Unhandled exception on {request.url}:\n{tb}") |
| return JSONResponse( |
| status_code=500, |
| content={"detail": str(exc), "type": type(exc).__name__}, |
| ) |
|
|
| _sessions: dict[str, ProcurementWorkflowExecutor] = {} |
| _bearer = HTTPBearer() |
|
|
| |
|
|
| def _b64(data: bytes) -> str: |
| return base64.urlsafe_b64encode(data).rstrip(b"=").decode() |
|
|
| def _sign(msg: str) -> str: |
| return _b64(hmac.new(_JWT_SECRET.encode(), msg.encode(), hashlib.sha256).digest()) |
|
|
| def create_token(username: str, role: str) -> str: |
| header = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) |
| payload = _b64(json.dumps({"sub": username, "role": role, "exp": int(time.time()) + _JWT_EXPIRY}).encode()) |
| return f"{header}.{payload}.{_sign(header + '.' + payload)}" |
|
|
| def verify_token(token: str) -> dict: |
| try: |
| h, p, sig = token.split(".") |
| if _sign(h + "." + p) != sig: |
| raise ValueError("bad sig") |
| claims = json.loads(base64.urlsafe_b64decode(p + "==")) |
| if claims["exp"] < int(time.time()): |
| raise ValueError("expired") |
| return claims |
| except Exception: |
| raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") |
|
|
| def current_user(creds: HTTPAuthorizationCredentials = Depends(_bearer)) -> dict: |
| return verify_token(creds.credentials) |
|
|
| |
|
|
| def _ser(state: Any) -> dict: |
| if state is None: |
| return {} |
| return dict(state) |
|
|
| def _fmt_events(events: list) -> list[dict]: |
| out = [] |
| for e in (events or []): |
| try: |
| if not isinstance(e, dict) or not e: |
| continue |
| node = next(iter(e)) |
| updates = e[node] |
| if not isinstance(updates, dict): |
| out.append({"node": node, "updates": {}}) |
| continue |
| safe = {} |
| for k, v in updates.items(): |
| if isinstance(v, str): |
| safe[k] = v[:400] |
| elif isinstance(v, (int, float, bool, type(None))): |
| safe[k] = v |
| elif isinstance(v, list): |
| safe[k] = [str(x)[:200] if not isinstance(x, (int, float, bool, dict)) else x for x in v[:5]] |
| else: |
| safe[k] = str(v)[:200] |
| out.append({"node": node, "updates": safe}) |
| except Exception: |
| pass |
| return out |
|
|
| |
|
|
| class LoginRequest(BaseModel): |
| username: str |
| password: str |
|
|
| @app.post("/auth/login", tags=["auth"]) |
| def login(body: LoginRequest): |
| user = USERS.get(body.username) |
| if not user or user["password"] != body.password: |
| raise HTTPException(status_code=401, detail="Invalid credentials") |
| token = create_token(body.username, user["role"]) |
| return {"access_token": token, "token_type": "bearer", |
| "username": body.username, "name": user["name"], "role": user["role"]} |
|
|
| @app.get("/auth/me", tags=["auth"]) |
| def me(user: dict = Depends(current_user)): |
| u = USERS[user["sub"]] |
| return {"username": user["sub"], "name": u["name"], "role": user["role"]} |
|
|
| |
|
|
| class ParseRequest(BaseModel): |
| raw_input: str = Field(..., example="We need servers for our startup, budget around 4k a month") |
|
|
| class StartRequest(BaseModel): |
| procurement_request: str = Field(default="", example="We need enterprise cloud infrastructure") |
| budget_limit: float = Field(default=5000.0, gt=0, example=5500.0) |
| raw_input: str = Field(default="", example="") |
|
|
| class DecideRequest(BaseModel): |
| approve: bool = Field(..., example=True) |
|
|
| @app.get("/health") |
| def health(): |
| return {"status": "ok", "graph_ready": _graph is not None} |
|
|
| @app.get("/testcases", tags=["workflow"]) |
| def get_test_cases(): |
| return TEST_CASES |
|
|
| @app.post("/workflow/parse", tags=["workflow"]) |
| def parse_prose(body: ParseRequest, user: dict = Depends(current_user)): |
| """Use the LLM to extract structured fields from free-form prose. Preview before running agents.""" |
| try: |
| from langchain_core.prompts import PromptTemplate |
| llm = create_llm() |
| prompt = PromptTemplate( |
| input_variables=["raw"], |
| template=( |
| "You are a procurement intake assistant. Extract structured procurement details " |
| "from the user's free-form description below.\n\n" |
| "User input:\n{raw}\n\n" |
| "Reply with ONLY valid JSON β no markdown, no explanation:\n" |
| '{{\n' |
| ' "procurement_request": "<concise 1-2 sentence professional description of what is needed>",\n' |
| ' "budget_limit": <monthly budget as a number, default 5000 if not mentioned>,\n' |
| ' "confidence": "<high|medium|low>",\n' |
| ' "assumptions": "<brief note on any assumptions made>"\n' |
| '}}' |
| ), |
| ) |
| response = llm.invoke(prompt.format(raw=body.raw_input)) |
| content = response.content.strip() |
| if content.startswith("```"): |
| content = content.split("```")[1] |
| if content.startswith("json"): |
| content = content[4:] |
| content = content.strip() |
| parsed = json.loads(content) |
| return { |
| "raw_input": body.raw_input, |
| "procurement_request": parsed.get("procurement_request", body.raw_input), |
| "budget_limit": float(parsed.get("budget_limit", 5000)), |
| "confidence": parsed.get("confidence", "medium"), |
| "assumptions": parsed.get("assumptions", ""), |
| } |
| except json.JSONDecodeError: |
| return { |
| "raw_input": body.raw_input, |
| "procurement_request": body.raw_input, |
| "budget_limit": 5000.0, |
| "confidence": "low", |
| "assumptions": "Could not parse LLM response; using raw input verbatim.", |
| } |
| except Exception as exc: |
| tb = traceback.format_exc() |
| print(f"parse_prose error:\n{tb}") |
| raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}") |
|
|
| @app.post("/workflow/start", tags=["workflow"]) |
| def start_workflow(body: StartRequest, user: dict = Depends(current_user)): |
| try: |
| executor = ProcurementWorkflowExecutor(_graph, _memory) |
| _sessions[executor.thread_id] = executor |
| state, events = executor.start_workflow( |
| body.procurement_request, |
| body.budget_limit, |
| raw_input=body.raw_input, |
| ) |
| s = _ser(state) |
| return { |
| "thread_id": executor.thread_id, |
| "status": "awaiting_approval" if s.get("analysis_approved") else "rejected", |
| "analysis_approved": s.get("analysis_approved"), |
| "selected_vendor": s.get("selected_vendor"), |
| "vendor_options": s.get("vendor_options"), |
| "budget_limit": s.get("budget_limit"), |
| "procurement_request": s.get("procurement_request"), |
| "logs": s.get("logs"), |
| "events": _fmt_events(events), |
| "triggered_by": user["sub"], |
| } |
| except Exception as exc: |
| tb = traceback.format_exc() |
| print(f"start_workflow error:\n{tb}") |
| raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}") |
|
|
| @app.get("/workflow/{thread_id}", tags=["workflow"]) |
| def get_workflow(thread_id: str, user: dict = Depends(current_user)): |
| ex = _sessions.get(thread_id) |
| if not ex: |
| raise HTTPException(404, f"Thread '{thread_id}' not found") |
| return {"thread_id": thread_id, "state": _ser(ex.get_state())} |
|
|
| @app.post("/workflow/{thread_id}/decide", tags=["workflow"]) |
| def decide(thread_id: str, body: DecideRequest, user: dict = Depends(current_user)): |
| ex = _sessions.get(thread_id) |
| if not ex: |
| raise HTTPException(404, f"Thread '{thread_id}' not found") |
| try: |
| state, events = ex.approve_vendor(body.approve) |
| s = _ser(state) |
| outcome = ("contract_generated" if s.get("contract_draft") else "approved_pending_contract") if body.approve else "rejected_by_human" |
| return { |
| "thread_id": thread_id, |
| "outcome": outcome, |
| "human_approved": s.get("human_approved"), |
| "contract_draft": s.get("contract_draft"), |
| "selected_vendor": s.get("selected_vendor"), |
| "logs": s.get("logs"), |
| "events": _fmt_events(events), |
| "decided_by": user["sub"], |
| } |
| except Exception as exc: |
| tb = traceback.format_exc() |
| print(f"decide error:\n{tb}") |
| raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}") |
|
|
| |
|
|
| @app.get("/", response_class=HTMLResponse, include_in_schema=False) |
| def root(): |
| return HTMLResponse(content=r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> |
| <title>Procurement AI System</title> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0} |
| body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh} |
| /* βββ LOGIN βββ */ |
| #login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem} |
| .login-box{background:#1e293b;border:1px solid #334155;border-radius:1rem;padding:2.5rem;width:100%;max-width:420px} |
| .login-box h1{font-size:1.4rem;font-weight:700;color:#93c5fd;margin-bottom:.4rem} |
| .login-box p{color:#64748b;font-size:.85rem;margin-bottom:1.5rem} |
| .users-hint{background:#0f172a;border-radius:.5rem;padding:.75rem;margin-bottom:1.5rem;font-size:.78rem;color:#94a3b8;line-height:1.8} |
| .users-hint strong{color:#e2e8f0} |
| input[type=text],input[type=password],input[type=number]{width:100%;background:#0f172a;border:1px solid #334155;color:#e2e8f0;border-radius:.5rem;padding:.65rem .9rem;font-size:.9rem;margin-bottom:.75rem;outline:none} |
| input:focus{border-color:#3b82f6} |
| .btn{padding:.7rem 1.2rem;border:none;border-radius:.5rem;font-size:.9rem;font-weight:600;cursor:pointer;transition:.15s;display:inline-block} |
| .btn-full{width:100%} |
| .btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8} |
| .btn-secondary{background:#1e293b;color:#94a3b8;border:1px solid #334155}.btn-secondary:hover{background:#273548} |
| .btn-success{background:#16a34a;color:#fff}.btn-success:hover{background:#15803d} |
| .btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c} |
| .btn-outline{background:transparent;color:#93c5fd;border:1px solid #3b82f6}.btn-outline:hover{background:#1e3a5f} |
| .btn-sm{padding:.35rem .8rem;font-size:.8rem} |
| .btn:disabled{opacity:.45;cursor:not-allowed} |
| .err{color:#f87171;font-size:.82rem;margin-top:.5rem} |
| /* βββ APP βββ */ |
| #app-page{display:none;flex-direction:column;min-height:100vh} |
| 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} |
| header h1{font-size:1.1rem;font-weight:700} |
| header p{font-size:.8rem;opacity:.8} |
| .user-pill{background:rgba(255,255,255,.15);border-radius:999px;padding:.3rem .8rem;font-size:.8rem;display:flex;align-items:center;gap:.5rem} |
| main{flex:1;max-width:1200px;width:100%;margin:0 auto;padding:1.5rem 1rem;display:grid;grid-template-columns:1fr 1fr;gap:1.5rem} |
| @media(max-width:750px){main{grid-template-columns:1fr}} |
| .card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.25rem} |
| .card h2{font-size:.95rem;font-weight:600;color:#93c5fd;margin-bottom:1rem} |
| /* βββ TABS βββ */ |
| .tabs{display:flex;gap:0;margin-bottom:1rem;border-bottom:1px solid #334155} |
| .tab{background:none;border:none;border-bottom:2px solid transparent;padding:.5rem 1rem;font-size:.85rem;color:#64748b;cursor:pointer;margin-bottom:-1px;transition:.15s} |
| .tab.active{color:#93c5fd;border-bottom-color:#3b82f6;font-weight:600} |
| .tab-panel{display:none}.tab-panel.active{display:block} |
| /* βββ PROSE PANEL βββ */ |
| .prose-area{width:100%;background:#0f172a;border:1px solid #334155;color:#e2e8f0;border-radius:.5rem;padding:.75rem .9rem;font-size:.88rem;resize:vertical;outline:none;min-height:110px;line-height:1.6} |
| .prose-area:focus{border-color:#3b82f6} |
| .parse-preview{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:.9rem;margin:1rem 0;font-size:.82rem} |
| .parse-preview .pf-label{color:#64748b;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.2rem} |
| .parse-preview .pf-val{color:#e2e8f0;margin-bottom:.6rem} |
| .confidence-badge{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.72rem;font-weight:600} |
| .conf-high{background:#14532d;color:#86efac} |
| .conf-medium{background:#713f12;color:#fde68a} |
| .conf-low{background:#450a0a;color:#fca5a5} |
| .parse-actions{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap} |
| /* βββ STRUCTURED PANEL βββ */ |
| label{font-size:.82rem;color:#94a3b8;display:block;margin-bottom:.3rem} |
| 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} |
| textarea:focus{border-color:#3b82f6} |
| .row{display:flex;gap:.75rem;align-items:flex-end;margin-top:.75rem} |
| .row>div{flex:1} |
| /* βββ TEST CASES βββ */ |
| .tc-list{display:flex;flex-direction:column;gap:.5rem} |
| .tc-btn{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:.55rem .9rem;cursor:pointer;text-align:left;color:#e2e8f0;font-size:.82rem;transition:.15s;width:100%} |
| .tc-btn:hover{border-color:#3b82f6;background:#172035} |
| .tc-btn small{display:block;color:#64748b;margin-top:.15rem} |
| /* βββ RESULTS βββ */ |
| .status-badge{display:inline-block;padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600} |
| .s-waiting{background:#1e3a5f;color:#93c5fd} |
| .s-ok{background:#14532d;color:#86efac} |
| .s-reject{background:#450a0a;color:#fca5a5} |
| .s-contract{background:#3b0764;color:#d8b4fe} |
| .s-parse{background:#1c1917;color:#d6d3d1;border:1px solid #44403c} |
| .vendor-card{background:#0f172a;border:1px solid #334155;border-radius:.5rem;padding:.75rem;margin-top:.75rem;font-size:.82rem} |
| .vendor-card b{color:#93c5fd} |
| .log-box{background:#0f172a;border:1px solid #1e293b;border-radius:.5rem;padding:.75rem;font-size:.75rem;font-family:monospace;color:#94a3b8;max-height:180px;overflow-y:auto;margin-top:.75rem;white-space:pre-wrap} |
| .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} |
| .sep{border:none;border-top:1px solid #334155;margin:.75rem 0} |
| .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} |
| @keyframes spin{to{transform:rotate(360deg)}} |
| .approve-row{display:flex;gap:.75rem;margin-top:.75rem} |
| .flow-steps{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem} |
| .fs{background:#0f172a;border:1px solid #475569;border-radius:.4rem;padding:.3rem .6rem;font-size:.75rem;color:#64748b} |
| .fs.done{border-color:#16a34a;color:#86efac} |
| .fs.active{border-color:#2563eb;color:#93c5fd;animation:pulse 1.5s ease-in-out infinite} |
| .fs.error{border-color:#dc2626;color:#fca5a5} |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}} |
| .arrow{color:#334155;align-self:center;font-size:.8rem} |
| .parsed-req-box{background:#0f172a;border:1px dashed #3b82f6;border-radius:.5rem;padding:.6rem .9rem;font-size:.82rem;color:#93c5fd;margin-bottom:.75rem;display:none} |
| .parsed-req-box span{color:#64748b;font-size:.75rem} |
| </style> |
| </head> |
| <body> |
| |
| <!-- ββββββββββββββββββ LOGIN ββββββββββββββββββ --> |
| <div id="login-page"> |
| <div class="login-box"> |
| <h1>π’ Procurement AI System</h1> |
| <p>Multi-Agent LangGraph Β· Groq Llama-3.3-70b Β· LangSmith</p> |
| <div class="users-hint"> |
| <strong>Demo accounts</strong><br> |
| admin / admin123 Β· buyer / buyer123<br> |
| manager / manager123 Β· analyst / analyst123 |
| </div> |
| <input type="text" id="uname" placeholder="Username" onkeydown="if(event.key==='Enter')doLogin()"/> |
| <input type="password" id="upass" placeholder="Password" onkeydown="if(event.key==='Enter')doLogin()"/> |
| <button class="btn btn-primary btn-full" onclick="doLogin()">Sign In</button> |
| <div id="login-err" class="err"></div> |
| </div> |
| </div> |
| |
| <!-- ββββββββββββββββββ APP ββββββββββββββββββ --> |
| <div id="app-page"> |
| <header> |
| <div> |
| <h1>Multi-Agent Procurement System</h1> |
| <p>LangGraph Β· LangChain Β· Groq llama-3.3-70b Β· LangSmith</p> |
| </div> |
| <div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap"> |
| <span class="user-pill">π€ <span id="user-display"></span></span> |
| <button class="btn btn-secondary btn-sm" onclick="doLogout()">Sign Out</button> |
| </div> |
| </header> |
| |
| <main> |
| <!-- LEFT col --> |
| <div style="display:flex;flex-direction:column;gap:1.5rem"> |
| |
| <!-- INPUT CARD with tabs --> |
| <div class="card"> |
| <h2>π Procurement Input</h2> |
| |
| <div class="tabs"> |
| <button class="tab active" onclick="switchTab('prose')" id="tab-prose">βοΈ Prose / Free Text</button> |
| <button class="tab" onclick="switchTab('structured')" id="tab-structured">π Structured Form</button> |
| </div> |
| |
| <!-- ββ PROSE TAB ββ --> |
| <div class="tab-panel active" id="panel-prose"> |
| <label>Describe your need in plain English</label> |
| <textarea class="prose-area" id="prose-input" placeholder="e.g. We need servers for our startup, something that auto-scales. Budget is around 4 thousand a month. Or: Looking for software licensing for about 500 users, no more than 2500 per month."></textarea> |
| |
| <div style="display:flex;gap:.6rem;margin-top:.6rem"> |
| <button class="btn btn-outline btn-sm" onclick="parseProse()" id="parse-btn">π Parse & Preview</button> |
| <span id="parse-status" style="font-size:.8rem;color:#64748b;align-self:center"></span> |
| </div> |
| |
| <!-- Parse preview --> |
| <div class="parse-preview" id="parse-preview" style="display:none"> |
| <div class="pf-label">Extracted Procurement Request</div> |
| <div class="pf-val" id="pp-request"></div> |
| <div class="pf-label">Monthly Budget</div> |
| <div class="pf-val" id="pp-budget"></div> |
| <div class="pf-label">Confidence <span id="pp-conf-badge"></span></div> |
| <div class="pf-val" id="pp-assumptions" style="color:#64748b;font-style:italic"></div> |
| <hr class="sep"/> |
| <div class="parse-actions"> |
| <button class="btn btn-primary btn-sm" onclick="runFromProse()" id="prose-run-btn">π Confirm & Run Agents</button> |
| <button class="btn btn-secondary btn-sm" onclick="editParsed()">βοΈ Edit before running</button> |
| </div> |
| </div> |
| <div id="prose-err" class="err"></div> |
| </div> |
| |
| <!-- ββ STRUCTURED TAB ββ --> |
| <div class="tab-panel" id="panel-structured"> |
| <!-- Shows parsed values when coming from prose, or manual entry --> |
| <div class="parsed-req-box" id="parsed-req-banner"> |
| <span>Parsed by AI β </span><span id="parsed-req-text"></span> |
| </div> |
| <label>Request Description</label> |
| <textarea id="req-text" rows="3" placeholder="Describe your procurement need in a sentence or twoβ¦"></textarea> |
| <div class="row"> |
| <div> |
| <label>Budget Limit ($/month)</label> |
| <input type="number" id="req-budget" value="5500" min="100" step="100" style="margin-bottom:0"/> |
| </div> |
| <button class="btn btn-primary btn-sm" onclick="startWorkflow()" id="start-btn">π Run Agents</button> |
| </div> |
| <div id="start-err" class="err"></div> |
| </div> |
| </div> |
| |
| <!-- TEST CASES --> |
| <div class="card"> |
| <h2>β‘ Preset Test Cases</h2> |
| <div class="tc-list" id="tc-list"></div> |
| </div> |
| |
| </div> |
| |
| <!-- RIGHT col: Results --> |
| <div style="display:flex;flex-direction:column;gap:1.5rem"> |
| |
| <div class="card"> |
| <h2>π Agent Pipeline</h2> |
| |
| <div class="flow-steps"> |
| <div class="fs" id="fs-parse">π€ Parse</div> |
| <div class="arrow">β</div> |
| <div class="fs" id="fs-research">π Research</div> |
| <div class="arrow">β</div> |
| <div class="fs" id="fs-analysis">π° Analysis</div> |
| <div class="arrow">β</div> |
| <div class="fs" id="fs-approval">π€ Gate</div> |
| <div class="arrow">β</div> |
| <div class="fs" id="fs-legal">π Legal</div> |
| </div> |
| |
| <div id="status-area" style="font-size:.85rem;color:#64748b"> |
| Run a workflow to see live results. |
| </div> |
| |
| <div id="vendor-area" style="display:none"> |
| <hr class="sep"/> |
| <div style="font-size:.82rem;color:#94a3b8;margin-bottom:.4rem">Selected Vendor</div> |
| <div class="vendor-card" id="vendor-card"></div> |
| </div> |
| |
| <div id="vendor-options-area" style="display:none;margin-top:.75rem"> |
| <div style="font-size:.82rem;color:#94a3b8;margin-bottom:.4rem">All Vendor Options</div> |
| <div id="vendor-options-list"></div> |
| </div> |
| </div> |
| |
| <div class="card" id="approval-card" style="display:none"> |
| <h2>π€ Human Approval Gate</h2> |
| <p style="font-size:.83rem;color:#94a3b8"> |
| LangGraph has <strong style="color:#93c5fd">paused</strong> at |
| <code>interrupt_before=["legal_node"]</code>. Review the vendor and decide. |
| </p> |
| <div class="approve-row"> |
| <button class="btn btn-success" onclick="decide(true)" id="approve-btn">β
Approve</button> |
| <button class="btn btn-danger" onclick="decide(false)" id="reject-btn">β Reject</button> |
| </div> |
| <div id="decide-err" class="err"></div> |
| </div> |
| |
| <div class="card" id="contract-card" style="display:none"> |
| <h2>π Generated Contract</h2> |
| <div class="contract-box" id="contract-box"></div> |
| </div> |
| |
| <div class="card"> |
| <h2>πͺ΅ Agent Audit Log</h2> |
| <div class="log-box" id="log-box">Logs will appear hereβ¦</div> |
| </div> |
| |
| </div> |
| </main> |
| </div> |
| |
| <script> |
| let TOKEN = null, THREAD = null; |
| let _parsedReq = "", _parsedBudget = 5000, _rawInput = ""; |
| |
| function apiHeaders() { |
| return { "Content-Type": "application/json", "Authorization": "Bearer " + TOKEN }; |
| } |
| |
| // ββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function doLogin() { |
| const u = document.getElementById("uname").value.trim(); |
| const p = document.getElementById("upass").value; |
| document.getElementById("login-err").textContent = ""; |
| try { |
| const r = await fetch("/auth/login", { |
| method: "POST", headers: {"Content-Type":"application/json"}, |
| body: JSON.stringify({username: u, password: p}) |
| }); |
| if (!r.ok) { document.getElementById("login-err").textContent = "Invalid username or password."; return; } |
| const d = await r.json(); |
| TOKEN = d.access_token; |
| document.getElementById("user-display").textContent = d.name + " (" + d.role + ")"; |
| document.getElementById("login-page").style.display = "none"; |
| document.getElementById("app-page").style.display = "flex"; |
| loadTestCases(); |
| } catch(e) { |
| document.getElementById("login-err").textContent = "Connection error."; |
| } |
| } |
| |
| function doLogout() { |
| TOKEN = null; THREAD = null; |
| document.getElementById("app-page").style.display = "none"; |
| document.getElementById("login-page").style.display = "flex"; |
| document.getElementById("upass").value = ""; |
| resetUI(); |
| } |
| |
| // ββ Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function switchTab(name) { |
| ["prose","structured"].forEach(t => { |
| document.getElementById("tab-"+t).classList.toggle("active", t===name); |
| document.getElementById("panel-"+t).classList.toggle("active", t===name); |
| }); |
| } |
| |
| // ββ Test cases βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadTestCases() { |
| try { |
| const r = await fetch("/testcases"); |
| const cases = await r.json(); |
| document.getElementById("tc-list").innerHTML = cases.map(tc => ` |
| <button class="tc-btn" onclick='fillTestCase(${JSON.stringify(tc)})'> |
| ${tc.label} |
| <small>$${tc.budget.toLocaleString()}/mo Β· ${tc.expected}</small> |
| </button>`).join(""); |
| } catch(e) {} |
| } |
| |
| function fillTestCase(tc) { |
| // Always fill structured form |
| document.getElementById("req-text").value = tc.request; |
| document.getElementById("req-budget").value = tc.budget; |
| document.getElementById("parsed-req-banner").style.display = "none"; |
| switchTab("structured"); |
| resetUI(); |
| appendLog("π Test case loaded: " + tc.label); |
| } |
| |
| // ββ Prose parse ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function parseProse() { |
| const raw = document.getElementById("prose-input").value.trim(); |
| if (!raw) { document.getElementById("prose-err").textContent = "Enter some text first."; return; } |
| document.getElementById("prose-err").textContent = ""; |
| document.getElementById("parse-preview").style.display = "none"; |
| document.getElementById("parse-btn").innerHTML = '<span class="spinner"></span>Parsingβ¦'; |
| document.getElementById("parse-btn").disabled = true; |
| document.getElementById("parse-status").textContent = ""; |
| |
| try { |
| const r = await fetch("/workflow/parse", { |
| method: "POST", headers: apiHeaders(), |
| body: JSON.stringify({raw_input: raw}) |
| }); |
| if (r.status === 401) { doLogout(); return; } |
| const d = await r.json(); |
| if (!r.ok) throw new Error(d.detail || "HTTP " + r.status); |
| |
| _rawInput = raw; |
| _parsedReq = d.procurement_request; |
| _parsedBudget = d.budget_limit; |
| |
| document.getElementById("pp-request").textContent = d.procurement_request; |
| document.getElementById("pp-budget").textContent = "$" + d.budget_limit.toLocaleString() + " / month"; |
| document.getElementById("pp-assumptions").textContent = d.assumptions || "β"; |
| |
| const confClass = {high:"conf-high", medium:"conf-medium", low:"conf-low"}[d.confidence] || "conf-medium"; |
| document.getElementById("pp-conf-badge").outerHTML = |
| `<span id="pp-conf-badge" class="confidence-badge ${confClass}">${d.confidence}</span>`; |
| |
| document.getElementById("parse-preview").style.display = "block"; |
| document.getElementById("parse-status").textContent = "β Parsed"; |
| |
| appendLog("π€ Prose parsed β " + d.procurement_request.substring(0,80) + (d.procurement_request.length>80?"β¦":"")); |
| appendLog(" Budget extracted: $" + d.budget_limit.toLocaleString() + " | Confidence: " + d.confidence); |
| } catch(e) { |
| document.getElementById("prose-err").textContent = "Parse error: " + e.message; |
| appendLog("ERROR parsing: " + e.message); |
| } finally { |
| document.getElementById("parse-btn").innerHTML = "π Parse & Preview"; |
| document.getElementById("parse-btn").disabled = false; |
| } |
| } |
| |
| function runFromProse() { |
| // Kick off agents directly with the parsed values |
| _startWorkflow(_parsedReq, _parsedBudget, _rawInput); |
| } |
| |
| function editParsed() { |
| // Prefill structured form and switch tab |
| document.getElementById("req-text").value = _parsedReq; |
| document.getElementById("req-budget").value = _parsedBudget; |
| document.getElementById("parsed-req-text").textContent = "AI-parsed from prose"; |
| document.getElementById("parsed-req-banner").style.display = "block"; |
| switchTab("structured"); |
| } |
| |
| // ββ Structured run βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function startWorkflow() { |
| const req = document.getElementById("req-text").value.trim(); |
| const budget = parseFloat(document.getElementById("req-budget").value); |
| if (!req) { document.getElementById("start-err").textContent = "Enter a procurement request."; return; } |
| if (!budget || budget <= 0) { document.getElementById("start-err").textContent = "Enter a valid budget."; return; } |
| document.getElementById("start-err").textContent = ""; |
| _startWorkflow(req, budget, ""); |
| } |
| |
| // ββ Core: calls /workflow/start ββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function _startWorkflow(req, budget, rawInput) { |
| resetUI(); |
| |
| const startBtn = document.getElementById("start-btn"); |
| const proseBtn = document.getElementById("prose-run-btn"); |
| startBtn.innerHTML = '<span class="spinner"></span>Runningβ¦'; startBtn.disabled = true; |
| proseBtn.innerHTML = '<span class="spinner"></span>Runningβ¦'; proseBtn.disabled = true; |
| |
| setStep("fs-parse", "active"); |
| appendLog("π Starting workflowβ¦"); |
| if (rawInput) appendLog(' Raw input: "' + rawInput.substring(0,80) + (rawInput.length>80?"...":"") + '"'); |
| |
| try { |
| const r = await fetch("/workflow/start", { |
| method: "POST", headers: apiHeaders(), |
| body: JSON.stringify({ |
| procurement_request: req, |
| budget_limit: budget, |
| raw_input: rawInput, |
| }) |
| }); |
| if (r.status === 401) { doLogout(); return; } |
| const d = await r.json(); |
| if (!r.ok) throw new Error(d.detail || "HTTP " + r.status); |
| |
| THREAD = d.thread_id; |
| |
| // Show events in log |
| (d.events || []).forEach(ev => appendLog(" [" + ev.node + "] processed")); |
| (d.logs || []).forEach(l => appendLog(l)); |
| |
| // If raw input was used, show what the LLM actually sent to research |
| if (rawInput && d.procurement_request && d.procurement_request !== req) { |
| appendLog("π€ ParseNode rewrote request β " + d.procurement_request.substring(0,80)); |
| } |
| |
| setStep("fs-parse", "done"); |
| setStep("fs-research", "done"); |
| setStep("fs-analysis", d.analysis_approved ? "done" : "error"); |
| |
| renderVendorOptions(d.vendor_options, d.budget_limit); |
| |
| if (d.analysis_approved) { |
| renderVendorCard(d.selected_vendor, d.budget_limit); |
| setStep("fs-approval", "active"); |
| document.getElementById("status-area").innerHTML = |
| '<span class="status-badge s-waiting">βΈ Awaiting Human Approval</span> ' + |
| '<span style="color:#64748b;font-size:.78rem">Graph paused Β· interrupt_before=["legal_node"]</span>'; |
| document.getElementById("approval-card").style.display = "block"; |
| } else { |
| renderVendorCard(d.selected_vendor, d.budget_limit); |
| document.getElementById("status-area").innerHTML = |
| '<span class="status-badge s-reject">β Analysis Rejected β all vendors exceed budget</span>'; |
| appendLog("β Budget exceeded. Workflow terminated."); |
| } |
| } catch(e) { |
| document.getElementById("status-area").innerHTML = '<span style="color:#f87171">Error: ' + e.message + '</span>'; |
| appendLog("ERROR: " + e.message); |
| } finally { |
| startBtn.innerHTML = "π Run Agents"; startBtn.disabled = false; |
| proseBtn.innerHTML = "π Confirm & Run Agents"; proseBtn.disabled = false; |
| } |
| } |
| |
| // ββ Decide βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function decide(approve) { |
| if (!THREAD) return; |
| setDecideButtons(false); |
| document.getElementById("decide-err").textContent = ""; |
| appendLog((approve ? "β
Human APPROVED" : "β Human REJECTED") + " β resuming graphβ¦"); |
| |
| try { |
| const r = await fetch("/workflow/" + THREAD + "/decide", { |
| method: "POST", headers: apiHeaders(), |
| body: JSON.stringify({approve}) |
| }); |
| if (r.status === 401) { doLogout(); return; } |
| const d = await r.json(); |
| if (!r.ok) throw new Error(d.detail || "HTTP " + r.status); |
| |
| (d.events || []).forEach(ev => appendLog(" [" + ev.node + "] processed")); |
| (d.logs || []).forEach(l => appendLog(l)); |
| |
| document.getElementById("approval-card").style.display = "none"; |
| |
| if (approve && d.contract_draft) { |
| setStep("fs-approval", "done"); |
| setStep("fs-legal", "done"); |
| document.getElementById("status-area").innerHTML = |
| '<span class="status-badge s-contract">β
Contract Generated</span>'; |
| document.getElementById("contract-box").textContent = d.contract_draft; |
| document.getElementById("contract-card").style.display = "block"; |
| appendLog("π Contract ready."); |
| } else if (approve) { |
| setStep("fs-approval", "done"); |
| document.getElementById("status-area").innerHTML = |
| '<span class="status-badge s-ok">Approved β contract pending</span>'; |
| } else { |
| setStep("fs-approval", "error"); |
| document.getElementById("status-area").innerHTML = |
| '<span class="status-badge s-reject">π« Rejected by Human Reviewer</span>'; |
| appendLog("π« Workflow rejected."); |
| } |
| } catch(e) { |
| document.getElementById("decide-err").textContent = "Error: " + e.message; |
| setDecideButtons(true); |
| } |
| } |
| |
| // ββ UI helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function resetUI() { |
| THREAD = null; |
| ["fs-parse","fs-research","fs-analysis","fs-approval","fs-legal"].forEach(id => { |
| document.getElementById(id).className = "fs"; |
| }); |
| document.getElementById("status-area").innerHTML = '<span style="color:#64748b">Ready to run.</span>'; |
| document.getElementById("vendor-area").style.display = "none"; |
| document.getElementById("vendor-options-area").style.display = "none"; |
| document.getElementById("approval-card").style.display = "none"; |
| document.getElementById("contract-card").style.display = "none"; |
| document.getElementById("log-box").textContent = "Logs will appear hereβ¦"; |
| document.getElementById("start-err").textContent = ""; |
| document.getElementById("decide-err").textContent = ""; |
| setDecideButtons(true); |
| } |
| |
| function setStep(id, state) { |
| document.getElementById(id).className = "fs " + state; |
| } |
| |
| function setDecideButtons(enabled) { |
| document.getElementById("approve-btn").disabled = !enabled; |
| document.getElementById("reject-btn").disabled = !enabled; |
| } |
| |
| function appendLog(msg) { |
| const el = document.getElementById("log-box"); |
| if (el.textContent === "Logs will appear hereβ¦") el.textContent = ""; |
| el.textContent += msg + "\n"; |
| el.scrollTop = el.scrollHeight; |
| } |
| |
| function renderVendorCard(v, budget) { |
| if (!v || !v.name) return; |
| const within = v.price_per_month <= budget; |
| document.getElementById("vendor-card").innerHTML = |
| `<b>${v.name}</b> <span class="status-badge ${within?'s-ok':'s-reject'}" style="margin-left:.4rem">${within?"β
Within":"β Over"} budget</span><br/> |
| <span style="color:#94a3b8">Service:</span> ${v.service_type} | |
| <span style="color:#94a3b8">Price:</span> $${v.price_per_month.toLocaleString()}/mo | |
| <span style="color:#94a3b8">Score:</span> ${v.reputation_score}/10<br/> |
| <span style="color:#94a3b8">Capabilities:</span> ${v.capabilities.join(" Β· ")}<br/> |
| <span style="color:#94a3b8">Terms:</span> ${v.contract_terms}`; |
| document.getElementById("vendor-area").style.display = "block"; |
| } |
| |
| function renderVendorOptions(vendors, budget) { |
| if (!vendors || !vendors.length) return; |
| document.getElementById("vendor-options-list").innerHTML = vendors.map(v => { |
| const w = v.price_per_month <= budget; |
| return `<div class="vendor-card" style="margin-bottom:.4rem"> |
| <b>${v.name}</b> |
| <span class="status-badge ${w?'s-ok':'s-reject'}" style="margin-left:.4rem">${w?"β
":"β"} $${v.price_per_month.toLocaleString()}/mo</span> |
| Score: ${v.reputation_score}/10 | ${v.capabilities.join(", ")} |
| </div>`; |
| }).join(""); |
| document.getElementById("vendor-options-area").style.display = "block"; |
| } |
| </script> |
| </body> |
| </html>""") |
|
|