Download app.py from PrernaKu/LegalDoc: direct link, hf CLI and curl.
- Browser
- Download file 42.3 kB
-
https://huggingface.co/spaces/PrernaKu/LegalDoc/resolve/main/app.py
- Command line
-
hf download hf://spaces/PrernaKu/LegalDoc/app.py
-
curl -L -o app.py https://huggingface.co/spaces/PrernaKu/LegalDoc/resolve/main/app.py
42.3 kB
| """ | |
| 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 | |
| # ββ Auth config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _JWT_SECRET = "procurement-demo-secret-2024" | |
| _JWT_EXPIRY = 7200 # 2 hours | |
| # Dummy user store { username: { password, role, display_name } } | |
| 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)"}, | |
| } | |
| # ββ Preset test scenarios exposed to the UI ββββββββββββββββββββββββββββββββββββ | |
| 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 singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _graph = None | |
| _memory = None | |
| 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=["*"]) | |
| 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() | |
| # ββ Minimal JWT (no external lib) βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Auth routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class LoginRequest(BaseModel): | |
| username: str | |
| password: str | |
| 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"]} | |
| def me(user: dict = Depends(current_user)): | |
| u = USERS[user["sub"]] | |
| return {"username": user["sub"], "name": u["name"], "role": user["role"]} | |
| # ββ Workflow routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| def health(): | |
| return {"status": "ok", "graph_ready": _graph is not None} | |
| def get_test_cases(): | |
| return TEST_CASES | |
| 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}") | |
| 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}") | |
| 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())} | |
| 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}") | |
| # ββ SPA ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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>""") | |