This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

491
negot8/backend/api.py Normal file
View File

@@ -0,0 +1,491 @@
# backend/api.py — FastAPI + Socket.IO API server for negoT8 Dashboard (Milestone 6)
import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
import socketio
import database as db
from tools.google_calendar import GoogleCalendarTool
# ─── Socket.IO server ──────────────────────────────────────────────────────────
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins="*",
logger=False,
engineio_logger=False,
)
# ─── FastAPI app ───────────────────────────────────────────────────────────────
app = FastAPI(title="negoT8 API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _row_to_dict(row) -> dict:
"""Convert an aiosqlite Row or plain dict, parsing JSON string fields."""
if row is None:
return {}
d = dict(row)
for field in (
"preferences", "proposal", "response", "concessions_made",
"resolution", "satisfaction_timeline", "concession_log",
):
if field in d and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except (json.JSONDecodeError, TypeError):
pass
return d
async def _build_negotiation_detail(negotiation_id: str) -> dict:
"""Assemble full negotiation object: meta + participants + rounds + analytics."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
# Negotiation meta
async with conn.execute(
"SELECT * FROM negotiations WHERE id = ?", (negotiation_id,)
) as cur:
neg = await cur.fetchone()
if neg is None:
return None
neg_dict = _row_to_dict(neg)
# Participants (with user display info)
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT p.*, u.username, u.display_name, u.personality, u.voice_id
FROM participants p
LEFT JOIN users u ON u.telegram_id = p.user_id
WHERE p.negotiation_id = ?""",
(negotiation_id,),
) as cur:
participants = [_row_to_dict(r) for r in await cur.fetchall()]
# Rounds
async with conn.execute(
"SELECT * FROM rounds WHERE negotiation_id = ? ORDER BY round_number",
(negotiation_id,),
) as cur:
rounds = [_row_to_dict(r) for r in await cur.fetchall()]
# Analytics
async with conn.execute(
"SELECT * FROM negotiation_analytics WHERE negotiation_id = ?",
(negotiation_id,),
) as cur:
analytics_row = await cur.fetchone()
analytics = _row_to_dict(analytics_row) if analytics_row else {}
if analytics.get("satisfaction_timeline") and isinstance(
analytics["satisfaction_timeline"], str
):
try:
analytics["satisfaction_timeline"] = json.loads(
analytics["satisfaction_timeline"]
)
except Exception:
analytics["satisfaction_timeline"] = []
if analytics.get("concession_log") and isinstance(
analytics["concession_log"], str
):
try:
analytics["concession_log"] = json.loads(analytics["concession_log"])
except Exception:
analytics["concession_log"] = []
return {
**neg_dict,
"participants": participants,
"rounds": rounds,
"analytics": analytics,
}
# ─── REST Endpoints ────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return {"status": "ok", "message": "negoT8 API v2 running"}
# ─── Google Calendar OAuth Callback ───────────────────────────────────────────
@app.get("/api/auth/google/callback", response_class=HTMLResponse)
async def google_calendar_callback(request: Request):
"""
Handles the redirect from Google after the user authorises calendar access.
- `code` — OAuth authorisation code from Google
- `state` — the user's telegram_id (set when building the auth URL)
"""
params = dict(request.query_params)
code = params.get("code")
state = params.get("state") # telegram_id
if not code or not state:
return HTMLResponse(
"<h2>❌ Missing code or state. Please try /connectcalendar again.</h2>",
status_code=400,
)
try:
telegram_id = int(state)
except ValueError:
return HTMLResponse("<h2>❌ Invalid state parameter.</h2>", status_code=400)
cal = GoogleCalendarTool()
success = await cal.exchange_code(telegram_id, code)
# Notify the user in Telegram (best-effort via direct Bot API call)
try:
import httpx
from config import TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B
msg = (
"✅ *Google Calendar connected!*\n\n"
"Your agent will now automatically use your real availability "
"when scheduling meetings — no need to mention times manually.\n\n"
"_Read-only access. Revoke anytime from myaccount.google.com → Security → Third-party apps._"
if success else
"❌ Failed to connect Google Calendar. Please try /connectcalendar again."
)
async with httpx.AsyncClient() as client:
for token in (TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B):
if not token:
continue
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": telegram_id, "text": msg, "parse_mode": "Markdown"},
timeout=8.0,
)
if resp.status_code == 200:
break
except Exception as e:
print(f"[OAuth] Could not send Telegram confirmation: {e}")
if success:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>✅ Google Calendar Connected!</h1>
<p>You can close this tab and return to Telegram.</p>
<p style="color:#666">negoT8 now has read-only access to your calendar.</p>
</body></html>
""")
else:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>❌ Connection Failed</h1>
<p>Please go back to Telegram and try <code>/connectcalendar</code> again.</p>
</body></html>
""", status_code=500)
@app.get("/api/negotiations")
async def list_negotiations():
"""Return all negotiations with participant count and latest status."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT n.*,
COUNT(p.user_id) AS participant_count
FROM negotiations n
LEFT JOIN participants p ON p.negotiation_id = n.id
GROUP BY n.id
ORDER BY n.created_at DESC"""
) as cur:
rows = await cur.fetchall()
negotiations = []
for row in rows:
d = _row_to_dict(row)
# Lightweight — don't embed full rounds here
negotiations.append(d)
return {"negotiations": negotiations, "total": len(negotiations)}
@app.get("/api/negotiations/{negotiation_id}")
async def get_negotiation(negotiation_id: str):
"""Return full negotiation detail: meta + participants + rounds + analytics."""
detail = await _build_negotiation_detail(negotiation_id)
if detail is None:
raise HTTPException(status_code=404, detail=f"Negotiation '{negotiation_id}' not found")
return detail
@app.get("/api/negotiations/{negotiation_id}/rounds")
async def get_negotiation_rounds(negotiation_id: str):
"""Return just the rounds for a negotiation (useful for live updates)."""
rounds = await db.get_rounds(negotiation_id)
parsed = [_row_to_dict(r) for r in rounds]
return {"negotiation_id": negotiation_id, "rounds": parsed, "count": len(parsed)}
@app.get("/api/negotiations/{negotiation_id}/analytics")
async def get_negotiation_analytics(negotiation_id: str):
"""Return analytics for a negotiation."""
analytics = await db.get_analytics(negotiation_id)
if analytics is None:
raise HTTPException(status_code=404, detail="Analytics not yet available for this negotiation")
# Parse JSON strings
for field in ("satisfaction_timeline", "concession_log"):
if isinstance(analytics.get(field), str):
try:
analytics[field] = json.loads(analytics[field])
except Exception:
analytics[field] = []
return analytics
@app.get("/api/users/{telegram_id}")
async def get_user(telegram_id: int):
"""Return a single user by Telegram ID."""
user = await db.get_user(telegram_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return dict(user)
@app.get("/api/stats")
async def get_stats():
"""High-level stats for the dashboard overview page."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT COUNT(*) AS c FROM negotiations") as cur:
total_neg = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'resolved'"
) as cur:
resolved = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'active'"
) as cur:
active = (await cur.fetchone())["c"]
async with conn.execute("SELECT COUNT(*) AS c FROM users") as cur:
total_users = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT AVG(fairness_score) AS avg_fs FROM negotiation_analytics"
) as cur:
row = await cur.fetchone()
avg_fairness = round(row["avg_fs"] or 0, 1)
async with conn.execute(
"""SELECT feature_type, COUNT(*) AS c
FROM negotiations
GROUP BY feature_type
ORDER BY c DESC"""
) as cur:
feature_breakdown = [dict(r) for r in await cur.fetchall()]
return {
"total_negotiations": total_neg,
"resolved": resolved,
"active": active,
"escalated": total_neg - resolved - active,
"total_users": total_users,
"avg_fairness_score": avg_fairness,
"feature_breakdown": feature_breakdown,
}
# ─── Open Contracts REST Endpoints ────────────────────────────────────────────
@app.get("/api/open-contracts")
async def list_open_contracts(status: str = "open"):
"""
Return open contracts (default: status=open).
Pass ?status=all to get every contract regardless of status.
"""
if status == "all":
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT oc.*,
u.username AS poster_username,
u.display_name AS poster_name,
COUNT(ca.id) AS application_count
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
GROUP BY oc.id
ORDER BY oc.created_at DESC"""
) as cur:
rows = await cur.fetchall()
contracts = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
import json as _json
d["requirements"] = _json.loads(d["requirements"])
except Exception:
pass
contracts.append(d)
else:
contracts = await db.get_open_contracts(status=status)
return {"contracts": contracts, "total": len(contracts)}
@app.get("/api/open-contracts/{contract_id}")
async def get_open_contract(contract_id: str):
"""
Return full detail for a single open contract including ranked applicants.
"""
contract = await db.get_open_contract(contract_id)
if contract is None:
raise HTTPException(status_code=404, detail=f"Contract '{contract_id}' not found")
applications = await db.get_applications(contract_id)
# applications are already sorted by match_score DESC from the DB helper
# Parse preferences in each application
for app_row in applications:
if isinstance(app_row.get("preferences"), str):
try:
app_row["preferences"] = json.loads(app_row["preferences"])
except Exception:
pass
return {
**contract,
"applications": applications,
"application_count": len(applications),
}
# ─── Socket.IO Events ──────────────────────────────────────────────────────────
@sio.event
async def connect(sid, environ):
print(f"[Socket.IO] Client connected: {sid}")
@sio.event
async def disconnect(sid):
print(f"[Socket.IO] Client disconnected: {sid}")
@sio.event
async def join_negotiation(sid, data):
"""
Client emits: { negotiation_id: "abc123" }
Server joins the socket into a room named after the negotiation_id.
Then immediately sends the current state.
"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id") or data.get("id", "")
if not negotiation_id:
await sio.emit("error", {"message": "negotiation_id required"}, to=sid)
return
await sio.enter_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} joined room: {negotiation_id}")
# Send current state immediately
detail = await _build_negotiation_detail(negotiation_id)
if detail:
await sio.emit("negotiation_state", detail, to=sid)
else:
await sio.emit("error", {"message": f"Negotiation '{negotiation_id}' not found"}, to=sid)
@sio.event
async def leave_negotiation(sid, data):
"""Client emits: { negotiation_id: "abc123" }"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id", "")
if negotiation_id:
await sio.leave_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} left room: {negotiation_id}")
# ─── Socket.IO emit helpers (called from run.py / negotiation engine) ──────────
async def emit_round_update(negotiation_id: str, round_data: dict):
"""
Called by the negotiation engine after each round completes.
Broadcasts to all dashboard clients watching this negotiation.
"""
await sio.emit(
"round_update",
{
"negotiation_id": negotiation_id,
"round": round_data,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_started(negotiation_id: str, feature_type: str, participants: list):
"""Broadcast when a new negotiation kicks off."""
await sio.emit(
"negotiation_started",
{
"negotiation_id": negotiation_id,
"feature_type": feature_type,
"participants": participants,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_resolved(negotiation_id: str, resolution: dict):
"""Broadcast the final resolution to all watchers."""
await sio.emit(
"negotiation_resolved",
{
"negotiation_id": negotiation_id,
"resolution": resolution,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
# ─── ASGI app (wraps FastAPI with Socket.IO) ───────────────────────────────────
# This is what uvicorn runs — it combines both the REST API and the WS server.
socket_app = socketio.ASGIApp(sio, other_asgi_app=app)