diff --git a/scripts/copilot.py b/scripts/copilot.py new file mode 100644 index 0000000..9a66eb3 --- /dev/null +++ b/scripts/copilot.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Staffing Co-Pilot — the anticipation layer. + +This isn't a tool you query. It's a system that watches your contracts, +your workers, your patterns — and tells you what you need before you +ask. It runs before the staffer starts their day. + +Output: a structured briefing that any UI can render. + +Layers: + 1. CONTRACT SCAN — what needs filling today + 2. PRE-MATCH — workers already identified per contract + 3. ALERTS — cert expirations, reliability drops, unfilled positions + 4. SUGGESTIONS — proactive opportunities the staffer wouldn't see + 5. BRIEFING — natural language summary for the staffer's morning + +Each layer feeds the next. The briefing is the human-facing output; +the structured data behind it feeds the agent gateway so any action +the staffer takes is one click away. +""" + +import json, time, sys +from datetime import datetime, timedelta +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +GW = "http://localhost:3700" +LH = "http://localhost:3100" + +def gw(path, body=None, timeout=180): + data = json.dumps(body).encode() if body else None + method = "POST" if body else "GET" + req = Request(f"{GW}{path}", data=data, method=method, + headers={"Content-Type": "application/json"} if body else {}) + try: + return json.loads(urlopen(req, timeout=timeout).read()) + except HTTPError as e: + return {"error": e.read().decode()[:200]} + except Exception as e: + return {"error": str(e)} + +def sql(query): + r = gw("/sql", {"sql": query}) + return r.get("rows", []) if "error" not in r else [] + +def gen(prompt, model="qwen3", max_tokens=400): + r = gw("/api/ai/generate", {"prompt": prompt, "model": model, + "max_tokens": max_tokens, "temperature": 0.3}) + text = r.get("text", "") + if "" in text: + text = text.split("")[-1].strip() + return text + +# ═══════════════════════════════════════════════════ +# TODAY'S CONTRACTS — simulated but structured like real ops +# ═══════════════════════════════════════════════════ + +TODAYS_CONTRACTS = [ + {"id": "C-4401", "client": "Midwest Logistics", "role": "Forklift Operator", + "state": "IL", "city": "Chicago", "headcount": 4, "min_rel": 0.8, + "certs": ["OSHA-10"], "priority": "high", "start": "7:00 AM", + "notes": "Warehouse expansion — client wants workers who've been there before"}, + {"id": "C-4402", "client": "Precision Manufacturing", "role": "Machine Operator", + "state": "IN", "headcount": 6, "min_rel": 0.75, "certs": [], + "priority": "medium", "start": "6:00 AM", + "notes": "2nd shift CNC line, prefer experienced operators"}, + {"id": "C-4403", "client": "CleanSpace Facilities", "role": "Sanitation Worker", + "state": "OH", "headcount": 2, "min_rel": 0.6, "certs": ["Hazmat"], + "priority": "medium", "start": "8:00 AM", + "notes": "Chemical plant — hazmat certification MANDATORY"}, + {"id": "C-4404", "client": "Amazon DSP (Springfield)", "role": "Loader", + "state": "IL", "city": "Springfield", "headcount": 8, "min_rel": 0.7, + "certs": [], "priority": "urgent", "start": "5:00 AM", + "notes": "Peak season surge — client called last night, needs bodies NOW"}, + {"id": "C-4405", "client": "AutoParts Direct", "role": "Quality Tech", + "state": "MO", "headcount": 2, "min_rel": 0.85, "certs": ["OSHA-30"], + "priority": "low", "start": "8:00 AM", + "notes": "ISO audit next week — need detail-oriented, compliant workers"}, + {"id": "C-4406", "client": "Great Lakes Steel", "role": "Welder", + "state": "OH", "city": "Cleveland", "headcount": 3, "min_rel": 0.8, + "certs": [], "priority": "high", "start": "6:30 AM", + "notes": "Structural welding — experienced only, no trainees"}, +] + +# ═══════════════════════════════════════════════════ +print("╔" + "═" * 63 + "╗") +print("║ STAFFING CO-PILOT — Morning Briefing ║") +print(f"║ {datetime.now().strftime('%A, %B %d, %Y')} ║") +print("╚" + "═" * 63 + "╝") + +briefing = {"contracts": [], "alerts": [], "suggestions": [], "stats": {}} + +# ═══════════════════════════════════════════════════ +# LAYER 1: CONTRACT SCAN + PRE-MATCH +# ═══════════════════════════════════════════════════ +print("\n┌─ TODAY'S CONTRACTS ────────────────────────────────") + +total_needed = 0 +total_prematched = 0 + +for c in sorted(TODAYS_CONTRACTS, key=lambda x: {"urgent": 0, "high": 1, "medium": 2, "low": 3}[x["priority"]]): + total_needed += c["headcount"] + priority_icon = {"urgent": "🔴", "high": "🟠", "medium": "🟡", "low": "🟢"}[c["priority"]] + + # Pre-match via hybrid search + filt = f"role = '{c['role']}' AND state = '{c['state']}' AND reliability >= {c['min_rel']}" + if c.get("city"): + filt += f" AND city = '{c['city']}'" + + r = gw("/search", { + "question": f"Best {c['role']} workers for {c['notes']}", + "sql_filter": filt, "top_k": c["headcount"] + 2, # extra for backups + "generate": False, + }) + + matches = r.get("sources", []) + filled = min(len(matches), c["headcount"]) + total_prematched += filled + backups = len(matches) - filled + + status = "✓ READY" if filled >= c["headcount"] else f"⚠ {c['headcount']-filled} UNFILLED" + + print(f"│") + print(f"│ {priority_icon} {c['id']} — {c['client']}") + print(f"│ {c['role']} × {c['headcount']} | {c.get('city', c['state'])}, {c['state']} | Start: {c['start']}") + print(f"│ Status: {status} ({filled} matched, {backups} backups)") + + # Show top matches with actionable info + for i, m in enumerate(matches[:c["headcount"]]): + text = m.get("chunk_text", "") + # Extract key info from the resume text + name = text.split("—")[0].strip() if "—" in text else m["doc_id"] + print(f"│ {i+1}. {name} (score: {m['score']:.2f})") + + if c.get("certs"): + print(f"│ ⚠ Cert required: {', '.join(c['certs'])}") + print(f"│ 📝 {c['notes']}") + + briefing["contracts"].append({ + "id": c["id"], "client": c["client"], "role": c["role"], + "filled": filled, "needed": c["headcount"], "priority": c["priority"], + "matches": [{"doc_id": m["doc_id"], "score": m["score"]} for m in matches[:c["headcount"]]], + }) + +fill_pct = total_prematched / max(total_needed, 1) * 100 +print(f"│") +print(f"│ 📊 Pre-match: {total_prematched}/{total_needed} ({fill_pct:.0f}%)") +print(f"└──────────────────────────────────────────────────────") + +# ═══════════════════════════════════════════════════ +# LAYER 2: ALERTS +# ═══════════════════════════════════════════════════ +print("\n┌─ ALERTS ──────────────────────────────────────────") + +# Alert: erratic workers on active matches +erratic = sql("SELECT name, role, city, state, ROUND(reliability,2) rel FROM ethereal_workers WHERE archetype = 'erratic' AND reliability < 0.4 ORDER BY reliability LIMIT 5") +if erratic: + print(f"│ ⚠ {len(erratic)} erratic workers with low reliability — flag for review:") + for w in erratic[:3]: + print(f"│ {w['name']} ({w['role']}, {w['city']}) — rel: {w['rel']}") + briefing["alerts"].append({"type": "erratic_workers", "count": len(erratic)}) + +# Alert: silent workers needing engagement +silent = sql("SELECT COUNT(*) cnt FROM ethereal_workers WHERE archetype = 'silent' AND responsiveness < 0.3") +if silent and silent[0].get("cnt", 0) > 0: + cnt = silent[0]["cnt"] + print(f"│ 📵 {cnt} silent workers with low responsiveness — may need different outreach channel") + briefing["alerts"].append({"type": "silent_workers", "count": cnt}) + +# Alert: state coverage gaps +for state in ["IL", "IN", "OH", "MO"]: + gap_roles = sql(f"SELECT role, COUNT(*) cnt FROM ethereal_workers WHERE state = '{state}' AND reliability >= 0.8 GROUP BY role HAVING COUNT(*) < 5 ORDER BY cnt") + if gap_roles: + thin = [f"{r['role']}({r['cnt']})" for r in gap_roles[:3]] + print(f"│ 📉 {state}: thin bench on {', '.join(thin)}") + briefing["alerts"].append({"type": "thin_bench", "state": state, "roles": thin}) + +print(f"└──────────────────────────────────────────────────────") + +# ═══════════════════════════════════════════════════ +# LAYER 3: PROACTIVE SUGGESTIONS +# ═══════════════════════════════════════════════════ +print("\n┌─ SUGGESTIONS ─────────────────────────────────────") + +# Suggestion: high-reliability workers not yet matched to any contract today +available = sql(""" + SELECT name, role, city, state, ROUND(reliability,2) rel, ROUND(availability,2) avail + FROM ethereal_workers + WHERE reliability >= 0.9 AND availability >= 0.9 AND archetype IN ('reliable', 'leader') + ORDER BY reliability DESC, availability DESC + LIMIT 5 +""") +if available: + print(f"│ 💎 Top available workers not yet assigned today:") + for w in available: + print(f"│ {w['name']} — {w['role']} in {w['city']}, {w['state']} (rel: {w['rel']}, avail: {w['avail']})") + briefing["suggestions"].append({"type": "top_available", "count": len(available)}) + +# Suggestion: roles with surplus capacity +surplus = sql(""" + SELECT role, state, COUNT(*) workers, ROUND(AVG(reliability),2) avg_rel + FROM ethereal_workers + WHERE reliability >= 0.8 + GROUP BY role, state + HAVING COUNT(*) > 20 + ORDER BY workers DESC + LIMIT 3 +""") +if surplus: + print(f"│ 📈 Deep bench — could fill larger orders:") + for s in surplus: + print(f"│ {s['role']} in {s['state']}: {s['workers']} workers (avg rel: {s['avg_rel']})") + briefing["suggestions"].append({"type": "deep_bench", "roles": [s["role"] for s in surplus]}) + +# Suggestion: check playbooks for optimization tips +pbs = gw("/playbooks?keyword=fill&limit=3") +playbooks = pbs.get("playbooks", []) if isinstance(pbs, dict) else [] +if playbooks: + print(f"│ 📚 From playbook: {playbooks[0].get('result', '?')[:70]}") + +print(f"└──────────────────────────────────────────────────────") + +# ═══════════════════════════════════════════════════ +# LAYER 4: MORNING BRIEFING (qwen3 generates) +# ═══════════════════════════════════════════════════ +print("\n┌─ BRIEFING ────────────────────────────────────────") + +briefing_data = f"""Today's summary: +- {len(TODAYS_CONTRACTS)} contracts, {total_needed} positions total +- Pre-matched: {total_prematched}/{total_needed} ({fill_pct:.0f}%) +- Urgent: {sum(1 for c in TODAYS_CONTRACTS if c['priority']=='urgent')} contracts need immediate attention +- High priority: {sum(1 for c in TODAYS_CONTRACTS if c['priority']=='high')} contracts +- Alerts: {len(briefing['alerts'])} items flagged +- Top available workers identified for proactive placement""" + +morning_brief = gen(f"""You are a staffing co-pilot. Write a concise morning briefing for a staffing coordinator. +Be direct, actionable, no fluff. Tell them what to focus on first. + +Data: +{briefing_data} + +Urgent contract: C-4404 Amazon DSP Springfield needs 8 loaders by 5 AM — this is your #1 priority. +High priority: C-4401 Midwest Logistics Chicago needs 4 forklift ops, C-4406 Great Lakes Steel Cleveland needs 3 welders. + +Write the briefing in 6 lines max. Start with the most urgent action.""", model="qwen3", max_tokens=300) + +print(f"│") +for line in morning_brief.strip().split("\n")[:8]: + if line.strip(): + print(f"│ {line.strip()}") +print(f"│") +print(f"└──────────────────────────────────────────────────────") + +# ═══════════════════════════════════════════════════ +# LAYER 5: ACTION QUEUE — ready for one-click execution +# ═══════════════════════════════════════════════════ +print("\n┌─ ACTION QUEUE (ready for staffer) ─────────────────") + +actions = [] +for c_data in briefing["contracts"]: + if c_data["filled"] < c_data["needed"]: + actions.append(f"⚠ FILL: {c_data['id']} needs {c_data['needed']-c_data['filled']} more {c_data['role']}(s)") + elif c_data["matches"]: + actions.append(f"📱 CONFIRM: {c_data['id']} — {c_data['filled']} workers pre-matched, send outreach") + +for a in actions[:8]: + print(f"│ {a}") + +if not actions: + print(f"│ ✓ All contracts pre-matched — confirm and send outreach") + +print(f"│") +print(f"│ Total actions: {len(actions)}") +print(f"└──────────────────────────────────────────────────────") + +# Log the briefing as a playbook entry +gw("/log", { + "operation": f"copilot_briefing: {total_prematched}/{total_needed} pre-matched, {len(briefing['alerts'])} alerts", + "approach": "5-layer anticipation: scan → match → alert → suggest → brief", + "result": f"fill_rate={fill_pct:.0f}%, actions={len(actions)}, urgent=1, high=2", + "context": "morning briefing for staffing coordinator", +}) + +# ═══════════════════════════════════════════════════ +# OUTPUT: structured JSON for any UI to render +# ═══════════════════════════════════════════════════ +briefing["stats"] = { + "total_contracts": len(TODAYS_CONTRACTS), + "total_needed": total_needed, + "total_prematched": total_prematched, + "fill_pct": fill_pct, + "actions": len(actions), + "alerts": len(briefing["alerts"]), +} + +# Write the structured briefing as JSON for the UI layer +with open("/tmp/copilot_briefing.json", "w") as f: + json.dump(briefing, f, indent=2) + +print(f"\n📋 Structured briefing saved to /tmp/copilot_briefing.json") +print(f" Any UI can render this — the data is ready.")