Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
app.py β HuggingFace Spaces entry point
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
|
| 8 |
import os
|
|
@@ -13,8 +13,7 @@ import threading
|
|
| 13 |
import json
|
| 14 |
from contextlib import asynccontextmanager
|
| 15 |
|
| 16 |
-
|
| 17 |
-
from fastapi import FastAPI, Request, Response
|
| 18 |
from fastapi.responses import JSONResponse
|
| 19 |
from pydantic import BaseModel
|
| 20 |
|
|
@@ -22,16 +21,16 @@ import router
|
|
| 22 |
import scheduler as sched
|
| 23 |
import sheets
|
| 24 |
|
|
|
|
| 25 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
-
#
|
| 27 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
|
| 29 |
@asynccontextmanager
|
| 30 |
-
async def lifespan(app):
|
| 31 |
-
# Run setup in background β don't block startup or hammer Sheets API
|
| 32 |
import asyncio
|
| 33 |
async def delayed_setup():
|
| 34 |
-
await asyncio.sleep(5)
|
| 35 |
try:
|
| 36 |
sheets.setup_sheets()
|
| 37 |
except Exception as e:
|
|
@@ -40,13 +39,29 @@ async def lifespan(app):
|
|
| 40 |
sched.start_scheduler()
|
| 41 |
yield
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
|
| 46 |
CHANNEL_ID = os.environ.get("SLACK_CHANNEL_ID", "")
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
|
|
|
|
|
|
|
| 50 |
if abs(time.time() - int(timestamp)) > 300:
|
| 51 |
return False
|
| 52 |
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
|
|
@@ -58,31 +73,32 @@ def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
|
|
| 58 |
return hmac.compare_digest(computed, signature)
|
| 59 |
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
async def slack_events(request: Request):
|
| 63 |
try:
|
| 64 |
body = await request.body()
|
| 65 |
-
|
| 66 |
if not body:
|
| 67 |
return JSONResponse({"ok": True})
|
| 68 |
|
| 69 |
payload = json.loads(body)
|
| 70 |
|
| 71 |
-
#
|
| 72 |
-
# Slack sends this unsigned to verify the endpoint exists
|
| 73 |
if payload.get("type") == "url_verification":
|
| 74 |
return JSONResponse({"challenge": payload["challenge"]})
|
| 75 |
|
| 76 |
-
#
|
| 77 |
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
| 78 |
signature = request.headers.get("X-Slack-Signature", "")
|
| 79 |
-
|
| 80 |
if not verify_slack_signature(body, timestamp, signature):
|
| 81 |
return JSONResponse({"error": "Invalid signature"}, status_code=403)
|
| 82 |
|
| 83 |
if payload.get("type") == "event_callback":
|
| 84 |
event = payload.get("event", {})
|
| 85 |
-
if event.get("type") == "message"
|
| 86 |
thread = threading.Thread(target=router.route_event, args=(event,))
|
| 87 |
thread.daemon = True
|
| 88 |
thread.start()
|
|
@@ -95,6 +111,10 @@ async def slack_events(request: Request):
|
|
| 95 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
class CandidateIn(BaseModel):
|
| 99 |
slack_user_id: str
|
| 100 |
name: str
|
|
@@ -102,9 +122,12 @@ class CandidateIn(BaseModel):
|
|
| 102 |
ft_slack_id: str
|
| 103 |
hr_slack_id: str
|
| 104 |
week0_start_date: str
|
| 105 |
-
stage: str = "week0"
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
@app.post("/admin/add_candidate")
|
| 108 |
async def add_candidate(data: CandidateIn):
|
| 109 |
try:
|
| 110 |
candidate = sheets.create_candidate(
|
|
@@ -115,130 +138,43 @@ async def add_candidate(data: CandidateIn):
|
|
| 115 |
hr_slack_id=data.hr_slack_id,
|
| 116 |
week0_start_date=data.week0_start_date,
|
| 117 |
stage=data.stage,
|
|
|
|
|
|
|
| 118 |
)
|
| 119 |
return JSONResponse({"ok": True, "candidate_id": candidate["candidate_id"]})
|
| 120 |
except Exception as e:
|
| 121 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
class CommitmentIn(BaseModel):
|
| 125 |
candidate_id: str
|
| 126 |
description: str
|
| 127 |
due_date: str
|
| 128 |
due_time: str
|
| 129 |
|
| 130 |
-
@app.post("/admin/log_commitment")
|
| 131 |
-
async def log_commitment(data: CommitmentIn):
|
| 132 |
-
commitment_id = sheets.log_commitment(
|
| 133 |
-
candidate_id=data.candidate_id,
|
| 134 |
-
description=data.description,
|
| 135 |
-
due_date=data.due_date,
|
| 136 |
-
due_time=data.due_time,
|
| 137 |
-
)
|
| 138 |
-
return JSONResponse({"ok": True, "commitment_id": commitment_id})
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
@app.get("/health")
|
| 142 |
-
async def health():
|
| 143 |
-
return JSONResponse({"status": "ok"})
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
-
# GRADIO β Dashboard UI (keeps Space alive)
|
| 148 |
-
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
-
|
| 150 |
-
def get_cohort_status():
|
| 151 |
-
"""Pull live candidate data from Sheets for the dashboard."""
|
| 152 |
-
try:
|
| 153 |
-
candidates = sheets.get_active_candidates()
|
| 154 |
-
if not candidates:
|
| 155 |
-
return "No active candidates."
|
| 156 |
-
lines = ["**Active candidates**\n"]
|
| 157 |
-
for c in candidates:
|
| 158 |
-
lines.append(
|
| 159 |
-
f"- **{c['name']}** β Stage: `{c['stage']}` | "
|
| 160 |
-
f"Status: `{c['status']}` | Misses: `{c['miss_count']}`"
|
| 161 |
-
)
|
| 162 |
-
return "\n".join(lines)
|
| 163 |
-
except Exception as e:
|
| 164 |
-
return f"Error loading candidates: {e}"
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def get_recent_reports():
|
| 168 |
-
"""Show last 10 report submissions across all candidates."""
|
| 169 |
-
try:
|
| 170 |
-
candidates = sheets.get_active_candidates()
|
| 171 |
-
all_reports = []
|
| 172 |
-
for c in candidates:
|
| 173 |
-
reports = sheets.get_reports_for_candidate(c["candidate_id"])
|
| 174 |
-
for r in reports:
|
| 175 |
-
r["candidate_name"] = c["name"]
|
| 176 |
-
all_reports.append(r)
|
| 177 |
-
all_reports.sort(key=lambda x: x.get("submitted_at", ""), reverse=True)
|
| 178 |
-
recent = all_reports[:10]
|
| 179 |
-
if not recent:
|
| 180 |
-
return "No reports yet."
|
| 181 |
-
lines = ["**Recent reports**\n"]
|
| 182 |
-
for r in recent:
|
| 183 |
-
flag = "β" if r.get("format_valid") == "True" else "β"
|
| 184 |
-
lines.append(
|
| 185 |
-
f"- {flag} **{r['candidate_name']}** β "
|
| 186 |
-
f"Day {r.get('day_number')} | "
|
| 187 |
-
f"Score: {r.get('quality_score', '-')} | "
|
| 188 |
-
f"{r.get('submitted_at', '')[:16]}"
|
| 189 |
-
)
|
| 190 |
-
return "\n".join(lines)
|
| 191 |
-
except Exception as e:
|
| 192 |
-
return f"Error loading reports: {e}"
|
| 193 |
|
| 194 |
-
|
| 195 |
-
def
|
| 196 |
-
"""Show evaluations awaiting HR confirmation."""
|
| 197 |
try:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
f"*{rec.get('recommendation')}* (awaiting HR)"
|
| 206 |
-
)
|
| 207 |
-
if not pending:
|
| 208 |
-
return "No pending evaluations."
|
| 209 |
-
return "**Pending HR confirmations**\n\n" + "\n".join(pending)
|
| 210 |
except Exception as e:
|
| 211 |
-
return
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
with gr.Blocks(title="ai_vidya Pipeline Agent") as gradio_app:
|
| 215 |
-
gr.Markdown("## ai_vidya Pipeline Agent")
|
| 216 |
-
gr.Markdown("Slack agent managing Week 0 β Probation for the AI Department intern pipeline.")
|
| 217 |
-
|
| 218 |
-
with gr.Row():
|
| 219 |
-
refresh_btn = gr.Button("Refresh", variant="secondary")
|
| 220 |
-
|
| 221 |
-
with gr.Row():
|
| 222 |
-
with gr.Column():
|
| 223 |
-
cohort_md = gr.Markdown(value=get_cohort_status())
|
| 224 |
-
with gr.Column():
|
| 225 |
-
pending_md = gr.Markdown(value=get_pending_evals())
|
| 226 |
-
|
| 227 |
-
with gr.Row():
|
| 228 |
-
reports_md = gr.Markdown(value=get_recent_reports())
|
| 229 |
-
|
| 230 |
-
def refresh():
|
| 231 |
-
return get_cohort_status(), get_pending_evals(), get_recent_reports()
|
| 232 |
-
|
| 233 |
-
refresh_btn.click(
|
| 234 |
-
fn=refresh,
|
| 235 |
-
outputs=[cohort_md, pending_md, reports_md],
|
| 236 |
-
)
|
| 237 |
|
| 238 |
|
| 239 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
-
#
|
| 241 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
app.py β HuggingFace Spaces entry point
|
| 3 |
+
Pure FastAPI + uvicorn. No Gradio.
|
| 4 |
+
Slack webhook at /slack/events.
|
| 5 |
+
Admin endpoints at /admin/*.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import os
|
|
|
|
| 13 |
import json
|
| 14 |
from contextlib import asynccontextmanager
|
| 15 |
|
| 16 |
+
from fastapi import FastAPI, Request
|
|
|
|
| 17 |
from fastapi.responses import JSONResponse
|
| 18 |
from pydantic import BaseModel
|
| 19 |
|
|
|
|
| 21 |
import scheduler as sched
|
| 22 |
import sheets
|
| 23 |
|
| 24 |
+
|
| 25 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
# LIFESPAN
|
| 27 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
|
| 29 |
@asynccontextmanager
|
| 30 |
+
async def lifespan(app: FastAPI):
|
|
|
|
| 31 |
import asyncio
|
| 32 |
async def delayed_setup():
|
| 33 |
+
await asyncio.sleep(5)
|
| 34 |
try:
|
| 35 |
sheets.setup_sheets()
|
| 36 |
except Exception as e:
|
|
|
|
| 39 |
sched.start_scheduler()
|
| 40 |
yield
|
| 41 |
|
| 42 |
+
|
| 43 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
# APP
|
| 45 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
app = FastAPI(
|
| 48 |
+
title="Intern Management Agent",
|
| 49 |
+
description="Slack agent managing intern pipeline β Week 0 through Probation.",
|
| 50 |
+
version="1.0.0",
|
| 51 |
+
lifespan=lifespan,
|
| 52 |
+
)
|
| 53 |
|
| 54 |
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
|
| 55 |
CHANNEL_ID = os.environ.get("SLACK_CHANNEL_ID", "")
|
| 56 |
|
| 57 |
|
| 58 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
# SLACK SIGNATURE VERIFICATION
|
| 60 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
|
| 62 |
def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
|
| 63 |
+
if not timestamp or not signature:
|
| 64 |
+
return False
|
| 65 |
if abs(time.time() - int(timestamp)) > 300:
|
| 66 |
return False
|
| 67 |
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
|
|
|
|
| 73 |
return hmac.compare_digest(computed, signature)
|
| 74 |
|
| 75 |
|
| 76 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
# SLACK EVENTS
|
| 78 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
|
| 80 |
+
@app.post("/slack/events", summary="Slack event webhook")
|
| 81 |
async def slack_events(request: Request):
|
| 82 |
try:
|
| 83 |
body = await request.body()
|
|
|
|
| 84 |
if not body:
|
| 85 |
return JSONResponse({"ok": True})
|
| 86 |
|
| 87 |
payload = json.loads(body)
|
| 88 |
|
| 89 |
+
# URL verification challenge β must respond before signature check
|
|
|
|
| 90 |
if payload.get("type") == "url_verification":
|
| 91 |
return JSONResponse({"challenge": payload["challenge"]})
|
| 92 |
|
| 93 |
+
# Verify all other requests
|
| 94 |
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
| 95 |
signature = request.headers.get("X-Slack-Signature", "")
|
|
|
|
| 96 |
if not verify_slack_signature(body, timestamp, signature):
|
| 97 |
return JSONResponse({"error": "Invalid signature"}, status_code=403)
|
| 98 |
|
| 99 |
if payload.get("type") == "event_callback":
|
| 100 |
event = payload.get("event", {})
|
| 101 |
+
if event.get("type") == "message":
|
| 102 |
thread = threading.Thread(target=router.route_event, args=(event,))
|
| 103 |
thread.daemon = True
|
| 104 |
thread.start()
|
|
|
|
| 111 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 112 |
|
| 113 |
|
| 114 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
# ADMIN β ADD CANDIDATE
|
| 116 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
+
|
| 118 |
class CandidateIn(BaseModel):
|
| 119 |
slack_user_id: str
|
| 120 |
name: str
|
|
|
|
| 122 |
ft_slack_id: str
|
| 123 |
hr_slack_id: str
|
| 124 |
week0_start_date: str
|
| 125 |
+
stage: str = "week0" # week0 | intern | probation_w1 | probation_w2
|
| 126 |
+
department: str = "general" # ai | hr | sales | product | general
|
| 127 |
+
channel_id: str = "" # Slack channel ID where intern posts reports
|
| 128 |
+
|
| 129 |
|
| 130 |
+
@app.post("/admin/add_candidate", summary="Register a new intern")
|
| 131 |
async def add_candidate(data: CandidateIn):
|
| 132 |
try:
|
| 133 |
candidate = sheets.create_candidate(
|
|
|
|
| 138 |
hr_slack_id=data.hr_slack_id,
|
| 139 |
week0_start_date=data.week0_start_date,
|
| 140 |
stage=data.stage,
|
| 141 |
+
department=data.department,
|
| 142 |
+
channel_id=data.channel_id,
|
| 143 |
)
|
| 144 |
return JSONResponse({"ok": True, "candidate_id": candidate["candidate_id"]})
|
| 145 |
except Exception as e:
|
| 146 |
return JSONResponse({"error": str(e)}, status_code=500)
|
| 147 |
|
| 148 |
|
| 149 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
# ADMIN β LOG COMMITMENT
|
| 151 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
|
| 153 |
class CommitmentIn(BaseModel):
|
| 154 |
candidate_id: str
|
| 155 |
description: str
|
| 156 |
due_date: str
|
| 157 |
due_time: str
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
@app.post("/admin/log_commitment", summary="Log a Week 2 delivery commitment")
|
| 161 |
+
async def log_commitment(data: CommitmentIn):
|
|
|
|
| 162 |
try:
|
| 163 |
+
commitment_id = sheets.log_commitment(
|
| 164 |
+
candidate_id=data.candidate_id,
|
| 165 |
+
description=data.description,
|
| 166 |
+
due_date=data.due_date,
|
| 167 |
+
due_time=data.due_time,
|
| 168 |
+
)
|
| 169 |
+
return JSONResponse({"ok": True, "commitment_id": commitment_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
except Exception as e:
|
| 171 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
+
# HEALTH
|
| 176 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
|
| 178 |
+
@app.get("/health", summary="Health check")
|
| 179 |
+
async def health():
|
| 180 |
+
return JSONResponse({"status": "ok"})
|