API
This commit is contained in:
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
94
app/routers/analytics.py
Normal file
94
app/routers/analytics.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import AnalyticsSummary
|
||||
from app.services.db import get_pool
|
||||
from app.services.hex_service import run_notebook
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@router.get("/distractions")
|
||||
async def distraction_analytics(user_id: str = Depends(get_current_user_id)):
|
||||
try:
|
||||
return await run_notebook("distraction_patterns", user_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Hex error: {e}")
|
||||
|
||||
|
||||
@router.get("/focus-trends")
|
||||
async def focus_trends(user_id: str = Depends(get_current_user_id)):
|
||||
try:
|
||||
return await run_notebook("focus_trends", user_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Hex error: {e}")
|
||||
|
||||
|
||||
@router.get("/weekly-report")
|
||||
async def weekly_report(user_id: str = Depends(get_current_user_id)):
|
||||
try:
|
||||
return await run_notebook("weekly_report", user_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Hex error: {e}")
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_analytics(user_id: str = Depends(get_current_user_id)):
|
||||
results = {}
|
||||
for key in ("distraction_patterns", "focus_trends", "weekly_report"):
|
||||
try:
|
||||
results[key] = await run_notebook(key, user_id)
|
||||
except Exception as e:
|
||||
results[key] = {"error": str(e)}
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AnalyticsSummary)
|
||||
async def analytics_summary(user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Direct Postgres queries — no Hex needed
|
||||
focus_minutes = await pool.fetchval(
|
||||
"""SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (ended_at - started_at)) / 60), 0)
|
||||
FROM sessions WHERE user_id = $1::uuid AND ended_at IS NOT NULL
|
||||
AND started_at > now() - interval '7 days'""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
sessions_completed = await pool.fetchval(
|
||||
"""SELECT COUNT(*) FROM sessions
|
||||
WHERE user_id = $1::uuid AND status = 'completed'
|
||||
AND started_at > now() - interval '7 days'""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
tasks_completed = await pool.fetchval(
|
||||
"""SELECT COUNT(*) FROM tasks
|
||||
WHERE user_id = $1::uuid AND status = 'done'
|
||||
AND updated_at > now() - interval '7 days'""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
top_distractors = await pool.fetch(
|
||||
"""SELECT app_name, COUNT(*) as count
|
||||
FROM distractions
|
||||
WHERE user_id = $1::uuid AND detected_at > now() - interval '7 days'
|
||||
GROUP BY app_name ORDER BY count DESC LIMIT 5""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
avg_attention = await pool.fetchval(
|
||||
"""SELECT AVG((checkpoint->>'attention_score')::float)
|
||||
FROM sessions
|
||||
WHERE user_id = $1::uuid AND checkpoint->>'attention_score' IS NOT NULL
|
||||
AND started_at > now() - interval '7 days'""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
return AnalyticsSummary(
|
||||
total_focus_minutes=float(focus_minutes or 0),
|
||||
sessions_completed=sessions_completed or 0,
|
||||
tasks_completed=tasks_completed or 0,
|
||||
top_distractors=[{"app_name": r["app_name"], "count": r["count"]} for r in top_distractors],
|
||||
avg_attention_score=float(avg_attention) if avg_attention else None,
|
||||
)
|
||||
145
app/routers/auth.py
Normal file
145
app/routers/auth.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.middleware.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user_id,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.models import (
|
||||
AppleAuthRequest,
|
||||
AuthResponse,
|
||||
DeviceTokenRequest,
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
UserOut,
|
||||
)
|
||||
from app.services import push
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _build_auth_response(user_row) -> AuthResponse:
|
||||
user_id = str(user_row["id"])
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(user_id),
|
||||
refresh_token=create_refresh_token(user_id),
|
||||
expires_in=3600,
|
||||
user=UserOut(
|
||||
id=user_row["id"],
|
||||
email=user_row["email"],
|
||||
display_name=user_row["display_name"],
|
||||
timezone=user_row["timezone"],
|
||||
created_at=user_row["created_at"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(req: RegisterRequest):
|
||||
pool = await get_pool()
|
||||
|
||||
existing = await pool.fetchrow("SELECT id FROM users WHERE email = $1", req.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Email already registered")
|
||||
|
||||
hashed = hash_password(req.password)
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO users (email, password_hash, display_name, timezone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, display_name, timezone, created_at""",
|
||||
req.email,
|
||||
hashed,
|
||||
req.display_name,
|
||||
req.timezone,
|
||||
)
|
||||
return _build_auth_response(row)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(req: LoginRequest):
|
||||
pool = await get_pool()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, email, password_hash, display_name, timezone, created_at FROM users WHERE email = $1",
|
||||
req.email,
|
||||
)
|
||||
if not row or not row["password_hash"]:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(req.password, row["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return _build_auth_response(row)
|
||||
|
||||
|
||||
@router.post("/apple", response_model=AuthResponse)
|
||||
async def apple_auth(req: AppleAuthRequest):
|
||||
# Decode the Apple identity token to extract the subject (user ID)
|
||||
# In production, verify signature against Apple's public keys
|
||||
from jose import jwt as jose_jwt
|
||||
|
||||
try:
|
||||
# Decode without verification for hackathon — in prod, fetch Apple's JWKS
|
||||
claims = jose_jwt.get_unverified_claims(req.identity_token)
|
||||
apple_user_id = claims["sub"]
|
||||
email = claims.get("email")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid Apple identity token")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
# Try to find existing user
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, email, display_name, timezone, created_at FROM users WHERE apple_user_id = $1",
|
||||
apple_user_id,
|
||||
)
|
||||
if row:
|
||||
return _build_auth_response(row)
|
||||
|
||||
# Check if email already exists (link accounts)
|
||||
if email:
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, email, display_name, timezone, created_at FROM users WHERE email = $1",
|
||||
email,
|
||||
)
|
||||
if row:
|
||||
await pool.execute("UPDATE users SET apple_user_id = $1 WHERE id = $2", apple_user_id, row["id"])
|
||||
return _build_auth_response(row)
|
||||
|
||||
# Create new user
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO users (apple_user_id, email, display_name, timezone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, display_name, timezone, created_at""",
|
||||
apple_user_id,
|
||||
email,
|
||||
req.full_name,
|
||||
"America/Chicago",
|
||||
)
|
||||
return _build_auth_response(row)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AuthResponse)
|
||||
async def refresh(req: RefreshRequest):
|
||||
payload = decode_token(req.refresh_token, expected_type="refresh")
|
||||
user_id = payload["sub"]
|
||||
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, email, display_name, timezone, created_at FROM users WHERE id = $1::uuid",
|
||||
user_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return _build_auth_response(row)
|
||||
|
||||
|
||||
@router.post("/device-token", status_code=204)
|
||||
async def register_device(req: DeviceTokenRequest, user_id: str = Depends(get_current_user_id)):
|
||||
await push.register_device_token(user_id, req.platform, req.token)
|
||||
308
app/routers/distractions.py
Normal file
308
app/routers/distractions.py
Normal file
@@ -0,0 +1,308 @@
|
||||
import json
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import (
|
||||
AnalyzeResultRequest,
|
||||
AnalyzeResultResponse,
|
||||
AppActivityRequest,
|
||||
AppActivityResponse,
|
||||
AppCheckRequest,
|
||||
AppCheckResponse,
|
||||
ScreenshotAnalysisResponse,
|
||||
)
|
||||
from app.services import llm
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="/distractions", tags=["distractions"])
|
||||
|
||||
|
||||
@router.post("/analyze-result", response_model=AnalyzeResultResponse)
|
||||
async def analyze_result(req: AnalyzeResultRequest, user_id: str = Depends(get_current_user_id)):
|
||||
"""Primary endpoint: receives pre-analyzed VLM JSON from device-side. No image.
|
||||
Works with or without an active session (VLM can run in always-on mode)."""
|
||||
pool = await get_pool()
|
||||
|
||||
# Session is optional — VLM can run without one (always-on mode)
|
||||
session = None
|
||||
if req.session_id:
|
||||
session = await pool.fetchrow(
|
||||
"SELECT id, task_id FROM sessions WHERE id = $1 AND user_id = $2::uuid AND status = 'active'",
|
||||
req.session_id,
|
||||
user_id,
|
||||
)
|
||||
|
||||
session_id_str = str(req.session_id) if req.session_id else None
|
||||
steps_updated = 0
|
||||
|
||||
# Side-effect 1: mark completed steps
|
||||
for completed_id in req.steps_completed:
|
||||
await pool.execute(
|
||||
"UPDATE steps SET status = 'done', completed_at = now() WHERE id = $1",
|
||||
completed_id,
|
||||
)
|
||||
steps_updated += 1
|
||||
|
||||
# Side-effect 2: update checkpoint_note on current step
|
||||
if req.current_step_id and req.checkpoint_note_update:
|
||||
await pool.execute(
|
||||
"UPDATE steps SET checkpoint_note = $1, last_checked_at = now() WHERE id = $2",
|
||||
req.checkpoint_note_update,
|
||||
req.current_step_id,
|
||||
)
|
||||
steps_updated += 1
|
||||
|
||||
# Side-effect 3: log distraction if off-task
|
||||
distraction_logged = False
|
||||
if not req.on_task:
|
||||
await pool.execute(
|
||||
"""INSERT INTO distractions (user_id, session_id, distraction_type, app_name,
|
||||
confidence, vlm_summary, nudge_shown)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)""",
|
||||
user_id,
|
||||
session_id_str,
|
||||
req.distraction_type,
|
||||
req.app_name,
|
||||
req.confidence,
|
||||
req.vlm_summary,
|
||||
req.confidence > 0.7,
|
||||
)
|
||||
distraction_logged = True
|
||||
|
||||
# Side-effect 4: store proactive action if friction detected
|
||||
proactive_action_id = None
|
||||
if req.friction.type != "none" and req.friction.confidence > 0.7:
|
||||
actions_json = json.dumps([a.model_dump() for a in req.friction.proposed_actions])
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO proactive_actions (user_id, session_id, friction_type, proposed_action)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4)
|
||||
RETURNING id""",
|
||||
user_id,
|
||||
session_id_str,
|
||||
req.friction.type,
|
||||
actions_json,
|
||||
)
|
||||
proactive_action_id = row["id"]
|
||||
|
||||
# Side-effect 5: update session checkpoint (if session exists)
|
||||
if session:
|
||||
checkpoint_data = {
|
||||
"last_vlm_summary": req.vlm_summary,
|
||||
"active_app": req.app_name,
|
||||
}
|
||||
if req.current_step_id:
|
||||
checkpoint_data["current_step_id"] = str(req.current_step_id)
|
||||
if req.inferred_task:
|
||||
checkpoint_data["inferred_task"] = req.inferred_task
|
||||
await pool.execute(
|
||||
"UPDATE sessions SET checkpoint = checkpoint || $1::jsonb WHERE id = $2",
|
||||
json.dumps(checkpoint_data),
|
||||
req.session_id,
|
||||
)
|
||||
|
||||
return AnalyzeResultResponse(
|
||||
side_effects_applied=True,
|
||||
steps_updated=steps_updated,
|
||||
distraction_logged=distraction_logged,
|
||||
proactive_action_id=proactive_action_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analyze-screenshot", response_model=ScreenshotAnalysisResponse)
|
||||
async def analyze_screenshot(
|
||||
screenshot: UploadFile = File(...),
|
||||
window_title: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
task_context: str = Form(...),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
pool = await get_pool()
|
||||
|
||||
# Verify session belongs to user
|
||||
session = await pool.fetchrow(
|
||||
"SELECT id, task_id FROM sessions WHERE id = $1::uuid AND user_id = $2::uuid AND status = 'active'",
|
||||
session_id,
|
||||
user_id,
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Active session not found")
|
||||
|
||||
screenshot_bytes = await screenshot.read()
|
||||
context = json.loads(task_context)
|
||||
|
||||
# Call Claude Vision
|
||||
analysis = await llm.analyze_screenshot(screenshot_bytes, window_title, context)
|
||||
|
||||
# Side-effect: update step statuses
|
||||
for completed_id in analysis.get("steps_completed", []):
|
||||
await pool.execute(
|
||||
"UPDATE steps SET status = 'done', completed_at = now() WHERE id = $1::uuid",
|
||||
str(completed_id),
|
||||
)
|
||||
|
||||
# Side-effect: update checkpoint_note on current step
|
||||
current_step_id = analysis.get("current_step_id")
|
||||
checkpoint_update = analysis.get("checkpoint_note_update")
|
||||
if current_step_id and checkpoint_update:
|
||||
await pool.execute(
|
||||
"UPDATE steps SET checkpoint_note = $1, last_checked_at = now() WHERE id = $2::uuid",
|
||||
checkpoint_update,
|
||||
str(current_step_id),
|
||||
)
|
||||
|
||||
# Side-effect: log distraction event if off-task
|
||||
if not analysis.get("on_task", True):
|
||||
await pool.execute(
|
||||
"""INSERT INTO distractions (user_id, session_id, distraction_type, app_name,
|
||||
confidence, vlm_summary, nudge_shown)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)""",
|
||||
user_id,
|
||||
session_id,
|
||||
analysis.get("distraction_type"),
|
||||
analysis.get("app_name"),
|
||||
analysis.get("confidence", 0),
|
||||
analysis.get("vlm_summary"),
|
||||
analysis.get("confidence", 0) > 0.7,
|
||||
)
|
||||
|
||||
# Update session checkpoint
|
||||
checkpoint_data = {
|
||||
"current_step_id": str(current_step_id) if current_step_id else None,
|
||||
"last_screenshot_analysis": analysis.get("vlm_summary"),
|
||||
"active_app": analysis.get("app_name"),
|
||||
}
|
||||
await pool.execute(
|
||||
"UPDATE sessions SET checkpoint = checkpoint || $1::jsonb WHERE id = $2::uuid",
|
||||
json.dumps(checkpoint_data),
|
||||
session_id,
|
||||
)
|
||||
|
||||
return ScreenshotAnalysisResponse(
|
||||
on_task=analysis.get("on_task", True),
|
||||
current_step_id=current_step_id,
|
||||
checkpoint_note_update=checkpoint_update,
|
||||
steps_completed=analysis.get("steps_completed", []),
|
||||
distraction_type=analysis.get("distraction_type"),
|
||||
app_name=analysis.get("app_name"),
|
||||
confidence=analysis.get("confidence", 0),
|
||||
gentle_nudge=analysis.get("gentle_nudge") if analysis.get("confidence", 0) > 0.7 else None,
|
||||
vlm_summary=analysis.get("vlm_summary"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/app-check", response_model=AppCheckResponse)
|
||||
async def app_check(req: AppCheckRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Check if app is in user's distraction list
|
||||
user = await pool.fetchrow(
|
||||
"SELECT distraction_apps FROM users WHERE id = $1::uuid",
|
||||
user_id,
|
||||
)
|
||||
is_distraction = req.app_bundle_id in (user["distraction_apps"] or []) if user else False
|
||||
|
||||
# Get pending task count and most urgent
|
||||
pending_count = await pool.fetchval(
|
||||
"SELECT COUNT(*) FROM tasks WHERE user_id = $1::uuid AND status NOT IN ('done', 'deferred')",
|
||||
user_id,
|
||||
)
|
||||
|
||||
urgent_task = await pool.fetchrow(
|
||||
"""SELECT t.id, t.title, t.priority, t.deadline,
|
||||
(SELECT COUNT(*) FROM steps WHERE task_id = t.id AND status != 'done') as steps_remaining,
|
||||
(SELECT title FROM steps WHERE task_id = t.id AND status = 'in_progress' ORDER BY sort_order LIMIT 1) as current_step
|
||||
FROM tasks t
|
||||
WHERE t.user_id = $1::uuid AND t.status NOT IN ('done', 'deferred')
|
||||
ORDER BY t.priority DESC, t.deadline ASC NULLS LAST
|
||||
LIMIT 1""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
most_urgent = None
|
||||
nudge = None
|
||||
if urgent_task:
|
||||
most_urgent = {
|
||||
"title": urgent_task["title"],
|
||||
"priority": urgent_task["priority"],
|
||||
"deadline": urgent_task["deadline"].isoformat() if urgent_task["deadline"] else None,
|
||||
"current_step": urgent_task["current_step"],
|
||||
"steps_remaining": urgent_task["steps_remaining"],
|
||||
}
|
||||
if is_distraction:
|
||||
nudge = f"Hey, quick check-in! You have {pending_count} task{'s' if pending_count != 1 else ''} waiting. Top priority: {urgent_task['title']}"
|
||||
if urgent_task["deadline"]:
|
||||
nudge += f" (due {urgent_task['deadline'].strftime('%b %d')})."
|
||||
else:
|
||||
nudge += "."
|
||||
|
||||
return AppCheckResponse(
|
||||
is_distraction_app=is_distraction,
|
||||
pending_task_count=pending_count,
|
||||
most_urgent_task=most_urgent,
|
||||
nudge=nudge,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/app-activity", response_model=AppActivityResponse)
|
||||
async def app_activity(req: AppActivityRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Verify session belongs to user and is active
|
||||
session = await pool.fetchrow(
|
||||
"SELECT id, task_id, checkpoint FROM sessions WHERE id = $1 AND user_id = $2::uuid AND status = 'active'",
|
||||
req.session_id,
|
||||
user_id,
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Active session not found")
|
||||
|
||||
# Log distraction
|
||||
await pool.execute(
|
||||
"""INSERT INTO distractions (user_id, session_id, distraction_type, app_name,
|
||||
duration_seconds, confidence, nudge_shown)
|
||||
VALUES ($1::uuid, $2, 'app_switch', $3, $4, 1.0, true)""",
|
||||
user_id,
|
||||
str(req.session_id),
|
||||
req.app_name,
|
||||
req.duration_seconds,
|
||||
)
|
||||
|
||||
# Count session distractions
|
||||
distraction_count = await pool.fetchval(
|
||||
"SELECT COUNT(*) FROM distractions WHERE session_id = $1",
|
||||
req.session_id,
|
||||
)
|
||||
|
||||
# Update session checkpoint distraction_count
|
||||
await pool.execute(
|
||||
"UPDATE sessions SET checkpoint = checkpoint || $1::jsonb WHERE id = $2",
|
||||
json.dumps({"distraction_count": distraction_count}),
|
||||
req.session_id,
|
||||
)
|
||||
|
||||
# Generate nudge using task + step context
|
||||
nudge = None
|
||||
if session["task_id"]:
|
||||
task = await pool.fetchrow("SELECT title FROM tasks WHERE id = $1", session["task_id"])
|
||||
current_step = await pool.fetchrow(
|
||||
"SELECT title, checkpoint_note FROM steps WHERE task_id = $1 AND status = 'in_progress' ORDER BY sort_order LIMIT 1",
|
||||
session["task_id"],
|
||||
)
|
||||
try:
|
||||
nudge = await llm.generate_app_activity_nudge(
|
||||
app_name=req.app_name,
|
||||
duration_seconds=req.duration_seconds,
|
||||
task_title=task["title"] if task else "your task",
|
||||
current_step_title=current_step["title"] if current_step else None,
|
||||
checkpoint_note=current_step["checkpoint_note"] if current_step else None,
|
||||
)
|
||||
except Exception:
|
||||
nudge = f"Hey, {req.app_name} grabbed your attention for a bit. Ready to jump back in?"
|
||||
|
||||
return AppActivityResponse(
|
||||
distraction_logged=True,
|
||||
session_distraction_count=distraction_count,
|
||||
gentle_nudge=nudge,
|
||||
)
|
||||
96
app/routers/proactive.py
Normal file
96
app/routers/proactive.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import (
|
||||
ProactiveExecuteRequest,
|
||||
ProactiveExecuteResponse,
|
||||
ProactivePreference,
|
||||
ProactivePreferencesResponse,
|
||||
ProactiveRespondRequest,
|
||||
ProactiveRespondResponse,
|
||||
)
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="/proactive", tags=["proactive"])
|
||||
|
||||
|
||||
@router.post("/respond", response_model=ProactiveRespondResponse)
|
||||
async def respond_to_action(req: ProactiveRespondRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, user_id FROM proactive_actions WHERE id = $1 AND user_id = $2::uuid",
|
||||
req.proactive_action_id,
|
||||
user_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Proactive action not found")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE proactive_actions SET user_choice = $1, chosen_action = $2, responded_at = now() WHERE id = $3",
|
||||
req.user_choice,
|
||||
req.chosen_action,
|
||||
req.proactive_action_id,
|
||||
)
|
||||
|
||||
return ProactiveRespondResponse(
|
||||
logged=True,
|
||||
should_execute=req.user_choice == "accepted",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute", response_model=ProactiveExecuteResponse)
|
||||
async def execute_action(req: ProactiveExecuteRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id, user_choice FROM proactive_actions WHERE id = $1 AND user_id = $2::uuid",
|
||||
req.proactive_action_id,
|
||||
user_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Proactive action not found")
|
||||
|
||||
# Mark as executed — actual execution happens device-side (AppleScript/Computer Use)
|
||||
# This endpoint logs that it was executed and can store results
|
||||
await pool.execute(
|
||||
"UPDATE proactive_actions SET executed = true WHERE id = $1",
|
||||
req.proactive_action_id,
|
||||
)
|
||||
|
||||
return ProactiveExecuteResponse(
|
||||
executed=True,
|
||||
result=f"Action {req.action_type} marked as executed. Device-side execution handles the actual work.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=ProactivePreferencesResponse)
|
||||
async def get_preferences(user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
rows = await pool.fetch(
|
||||
"""SELECT
|
||||
friction_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE user_choice = 'accepted') as accepted,
|
||||
(SELECT chosen_action FROM proactive_actions pa2
|
||||
WHERE pa2.user_id = $1::uuid AND pa2.friction_type = pa.friction_type
|
||||
AND pa2.user_choice = 'accepted'
|
||||
GROUP BY chosen_action ORDER BY COUNT(*) DESC LIMIT 1) as top_action
|
||||
FROM proactive_actions pa
|
||||
WHERE user_id = $1::uuid AND user_choice IS NOT NULL
|
||||
GROUP BY friction_type""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
preferences = {}
|
||||
for r in rows:
|
||||
total = r["total"]
|
||||
accepted = r["accepted"]
|
||||
preferences[r["friction_type"]] = ProactivePreference(
|
||||
preferred_action=r["top_action"],
|
||||
total_choices=total,
|
||||
acceptance_rate=accepted / total if total > 0 else 0.0,
|
||||
)
|
||||
|
||||
return ProactivePreferencesResponse(preferences=preferences)
|
||||
371
app/routers/sessions.py
Normal file
371
app/routers/sessions.py
Normal file
@@ -0,0 +1,371 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import (
|
||||
OpenSessionOut,
|
||||
ResumeCard,
|
||||
SessionCheckpointRequest,
|
||||
SessionEndRequest,
|
||||
SessionJoinRequest,
|
||||
SessionJoinResponse,
|
||||
SessionOut,
|
||||
SessionResumeResponse,
|
||||
SessionStartRequest,
|
||||
StepOut,
|
||||
)
|
||||
from app.services import llm, push
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
|
||||
SESSION_COLUMNS = "id, user_id, task_id, platform, started_at, ended_at, status, checkpoint, created_at"
|
||||
|
||||
|
||||
def _parse_session_row(row) -> SessionOut:
|
||||
result = dict(row)
|
||||
result["checkpoint"] = json.loads(result["checkpoint"]) if isinstance(result["checkpoint"], str) else result["checkpoint"]
|
||||
return SessionOut(**result)
|
||||
|
||||
|
||||
@router.post("/start", response_model=SessionOut, status_code=201)
|
||||
async def start_session(req: SessionStartRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Check if an active session already exists for this account
|
||||
active = await pool.fetchrow(
|
||||
f"SELECT {SESSION_COLUMNS} FROM sessions WHERE user_id = $1::uuid AND status = 'active'",
|
||||
user_id,
|
||||
)
|
||||
if active:
|
||||
# Idempotently return the existing active session and don't create a new one
|
||||
return _parse_session_row(active)
|
||||
|
||||
checkpoint = {}
|
||||
if req.task_id:
|
||||
task = await pool.fetchrow(
|
||||
"SELECT id, title, description FROM tasks WHERE id = $1 AND user_id = $2::uuid",
|
||||
req.task_id,
|
||||
user_id,
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE tasks SET status = 'in_progress', updated_at = now() WHERE id = $1",
|
||||
req.task_id,
|
||||
)
|
||||
checkpoint["goal"] = task["title"]
|
||||
|
||||
if req.work_app_bundle_ids:
|
||||
checkpoint["work_app_bundle_ids"] = req.work_app_bundle_ids
|
||||
|
||||
checkpoint["devices"] = [req.platform]
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""INSERT INTO sessions (user_id, task_id, platform, checkpoint)
|
||||
VALUES ($1::uuid, $2, $3, $4)
|
||||
RETURNING {SESSION_COLUMNS}""",
|
||||
user_id,
|
||||
req.task_id,
|
||||
req.platform,
|
||||
json.dumps(checkpoint),
|
||||
)
|
||||
|
||||
# Notify other devices about new session
|
||||
if req.task_id:
|
||||
task_row = await pool.fetchrow("SELECT title FROM tasks WHERE id = $1", req.task_id)
|
||||
task_title = task_row["title"] if task_row else "Focus Session"
|
||||
await push.send_push(user_id, "ipad" if req.platform == "mac" else "mac", {
|
||||
"type": "session_started",
|
||||
"session_id": str(row["id"]),
|
||||
"task_title": task_title,
|
||||
"platform": req.platform,
|
||||
})
|
||||
# Start Live Activity on all registered devices
|
||||
await push.send_activity_start(user_id, task_title, task_id=req.task_id)
|
||||
|
||||
return _parse_session_row(row)
|
||||
|
||||
|
||||
@router.get("/active", response_model=SessionOut)
|
||||
async def get_active_session(user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
f"SELECT {SESSION_COLUMNS} FROM sessions WHERE user_id = $1::uuid AND status = 'active'",
|
||||
user_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="No active session")
|
||||
return _parse_session_row(row)
|
||||
|
||||
|
||||
@router.get("/open", response_model=list[OpenSessionOut])
|
||||
async def get_open_sessions(user_id: str = Depends(get_current_user_id)):
|
||||
"""All active + interrupted sessions. Used by VLM on startup for session-aware analysis."""
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
f"SELECT {SESSION_COLUMNS} FROM sessions WHERE user_id = $1::uuid AND status IN ('active', 'interrupted') ORDER BY started_at DESC",
|
||||
user_id,
|
||||
)
|
||||
results = []
|
||||
for row in rows:
|
||||
checkpoint = json.loads(row["checkpoint"]) if isinstance(row["checkpoint"], str) else (row["checkpoint"] or {})
|
||||
task_info = None
|
||||
if row["task_id"]:
|
||||
task_row = await pool.fetchrow(
|
||||
"SELECT title, description FROM tasks WHERE id = $1", row["task_id"]
|
||||
)
|
||||
if task_row:
|
||||
task_info = {"title": task_row["title"], "goal": task_row["description"]}
|
||||
results.append(OpenSessionOut(
|
||||
id=row["id"],
|
||||
task_id=row["task_id"],
|
||||
task=task_info,
|
||||
status=row["status"],
|
||||
platform=row["platform"],
|
||||
started_at=row["started_at"],
|
||||
ended_at=row["ended_at"],
|
||||
checkpoint=checkpoint,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/{session_id}/join", response_model=SessionJoinResponse)
|
||||
async def join_session(
|
||||
session_id: UUID,
|
||||
req: SessionJoinRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
pool = await get_pool()
|
||||
|
||||
session = await pool.fetchrow(
|
||||
f"SELECT {SESSION_COLUMNS} FROM sessions WHERE id = $1 AND user_id = $2::uuid",
|
||||
session_id,
|
||||
user_id,
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session["status"] != "active":
|
||||
raise HTTPException(status_code=400, detail="Session is not active")
|
||||
|
||||
# Update checkpoint with joining device
|
||||
checkpoint = json.loads(session["checkpoint"]) if isinstance(session["checkpoint"], str) else (session["checkpoint"] or {})
|
||||
devices = checkpoint.get("devices", [session["platform"]])
|
||||
if req.platform not in devices:
|
||||
devices.append(req.platform)
|
||||
checkpoint["devices"] = devices
|
||||
if req.work_app_bundle_ids:
|
||||
checkpoint["work_app_bundle_ids"] = req.work_app_bundle_ids
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE sessions SET checkpoint = $1 WHERE id = $2",
|
||||
json.dumps(checkpoint),
|
||||
session_id,
|
||||
)
|
||||
|
||||
# Build response with full task + step context
|
||||
task_info = None
|
||||
current_step = None
|
||||
all_steps = []
|
||||
suggested_app_scheme = None
|
||||
suggested_app_name = None
|
||||
|
||||
if session["task_id"]:
|
||||
task_row = await pool.fetchrow(
|
||||
"SELECT id, title, description FROM tasks WHERE id = $1",
|
||||
session["task_id"],
|
||||
)
|
||||
if task_row:
|
||||
task_info = {
|
||||
"id": str(task_row["id"]),
|
||||
"title": task_row["title"],
|
||||
"goal": task_row["description"],
|
||||
}
|
||||
# Suggest a work app based on task
|
||||
try:
|
||||
suggestion = await llm.suggest_work_apps(task_row["title"], task_row["description"])
|
||||
suggested_app_scheme = suggestion.get("suggested_app_scheme")
|
||||
suggested_app_name = suggestion.get("suggested_app_name")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
step_rows = await pool.fetch(
|
||||
"""SELECT id, task_id, sort_order, title, description, estimated_minutes,
|
||||
status, checkpoint_note, last_checked_at, completed_at, created_at
|
||||
FROM steps WHERE task_id = $1 ORDER BY sort_order""",
|
||||
session["task_id"],
|
||||
)
|
||||
all_steps = [StepOut(**dict(r)) for r in step_rows]
|
||||
|
||||
# Find current in-progress step
|
||||
for s in step_rows:
|
||||
if s["status"] == "in_progress":
|
||||
current_step = {
|
||||
"id": str(s["id"]),
|
||||
"title": s["title"],
|
||||
"status": s["status"],
|
||||
"checkpoint_note": s["checkpoint_note"],
|
||||
}
|
||||
break
|
||||
|
||||
return SessionJoinResponse(
|
||||
session_id=session["id"],
|
||||
joined=True,
|
||||
task=task_info,
|
||||
current_step=current_step,
|
||||
all_steps=all_steps,
|
||||
suggested_app_scheme=suggested_app_scheme,
|
||||
suggested_app_name=suggested_app_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/checkpoint", response_model=SessionOut)
|
||||
async def save_checkpoint(
|
||||
session_id: UUID,
|
||||
req: SessionCheckpointRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
pool = await get_pool()
|
||||
|
||||
session = await pool.fetchrow(
|
||||
"SELECT id, status FROM sessions WHERE id = $1 AND user_id = $2::uuid",
|
||||
session_id,
|
||||
user_id,
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session["status"] != "active":
|
||||
raise HTTPException(status_code=400, detail="Session is not active")
|
||||
|
||||
checkpoint = req.model_dump(exclude_unset=True)
|
||||
if "current_step_id" in checkpoint and checkpoint["current_step_id"]:
|
||||
checkpoint["current_step_id"] = str(checkpoint["current_step_id"])
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""UPDATE sessions SET checkpoint = checkpoint || $1::jsonb
|
||||
WHERE id = $2
|
||||
RETURNING {SESSION_COLUMNS}""",
|
||||
json.dumps(checkpoint),
|
||||
session_id,
|
||||
)
|
||||
return _parse_session_row(row)
|
||||
|
||||
|
||||
@router.post("/{session_id}/end", response_model=SessionOut)
|
||||
async def end_session(
|
||||
session_id: UUID,
|
||||
req: SessionEndRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
pool = await get_pool()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""UPDATE sessions SET status = $1, ended_at = now()
|
||||
WHERE id = $2 AND user_id = $3::uuid AND status = 'active'
|
||||
RETURNING {SESSION_COLUMNS}""",
|
||||
req.status,
|
||||
session_id,
|
||||
user_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Active session not found")
|
||||
|
||||
# Notify other joined devices that session ended
|
||||
checkpoint = json.loads(row["checkpoint"]) if isinstance(row["checkpoint"], str) else (row["checkpoint"] or {})
|
||||
devices = checkpoint.get("devices", [])
|
||||
for device in devices:
|
||||
if device != row["platform"]:
|
||||
await push.send_push(user_id, device, {
|
||||
"type": "session_ended",
|
||||
"session_id": str(row["id"]),
|
||||
"ended_by": row["platform"],
|
||||
})
|
||||
|
||||
# End Live Activity on all devices
|
||||
task_title = checkpoint.get("goal", "Session ended")
|
||||
await push.send_activity_end(user_id, task_title=task_title, task_id=row["task_id"])
|
||||
|
||||
return _parse_session_row(row)
|
||||
|
||||
|
||||
@router.get("/{session_id}/resume", response_model=SessionResumeResponse)
|
||||
async def resume_session(session_id: UUID, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
session = await pool.fetchrow(
|
||||
f"SELECT {SESSION_COLUMNS} FROM sessions WHERE id = $1 AND user_id = $2::uuid",
|
||||
session_id,
|
||||
user_id,
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
checkpoint = json.loads(session["checkpoint"]) if isinstance(session["checkpoint"], str) else (session["checkpoint"] or {})
|
||||
|
||||
task_info = None
|
||||
current_step = None
|
||||
completed_count = 0
|
||||
total_count = 0
|
||||
next_step_title = None
|
||||
|
||||
if session["task_id"]:
|
||||
task_row = await pool.fetchrow(
|
||||
"SELECT id, title, description FROM tasks WHERE id = $1",
|
||||
session["task_id"],
|
||||
)
|
||||
if task_row:
|
||||
task_info = {"title": task_row["title"], "overall_goal": task_row["description"]}
|
||||
|
||||
step_rows = await pool.fetch(
|
||||
"SELECT id, sort_order, title, status, checkpoint_note, last_checked_at FROM steps WHERE task_id = $1 ORDER BY sort_order",
|
||||
session["task_id"],
|
||||
)
|
||||
total_count = len(step_rows)
|
||||
|
||||
found_current = False
|
||||
for s in step_rows:
|
||||
if s["status"] == "done":
|
||||
completed_count += 1
|
||||
elif s["status"] == "in_progress" and not found_current:
|
||||
current_step = {
|
||||
"id": str(s["id"]),
|
||||
"title": s["title"],
|
||||
"checkpoint_note": s["checkpoint_note"],
|
||||
"last_checked_at": s["last_checked_at"].isoformat() if s["last_checked_at"] else None,
|
||||
}
|
||||
found_current = True
|
||||
elif found_current and next_step_title is None:
|
||||
next_step_title = s["title"]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
last_activity = session["ended_at"] or session["started_at"]
|
||||
minutes_away = int((now - last_activity).total_seconds() / 60)
|
||||
|
||||
resume_card_data = await llm.generate_resume_card(
|
||||
task_title=task_info["title"] if task_info else "Unknown task",
|
||||
goal=task_info.get("overall_goal") if task_info else None,
|
||||
current_step_title=current_step["title"] if current_step else None,
|
||||
checkpoint_note=current_step["checkpoint_note"] if current_step else None,
|
||||
completed_count=completed_count,
|
||||
total_count=total_count,
|
||||
next_step_title=next_step_title,
|
||||
minutes_away=minutes_away,
|
||||
attention_score=checkpoint.get("attention_score"),
|
||||
)
|
||||
|
||||
return SessionResumeResponse(
|
||||
session_id=session["id"],
|
||||
task=task_info,
|
||||
current_step=current_step,
|
||||
progress={
|
||||
"completed": completed_count,
|
||||
"total": total_count,
|
||||
"attention_score": checkpoint.get("attention_score"),
|
||||
"distraction_count": checkpoint.get("distraction_count", 0),
|
||||
},
|
||||
resume_card=ResumeCard(**resume_card_data),
|
||||
)
|
||||
140
app/routers/steps.py
Normal file
140
app/routers/steps.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import StepOut, StepUpdate
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="", tags=["steps"])
|
||||
|
||||
STEP_COLUMNS = "s.id, s.task_id, s.sort_order, s.title, s.description, s.estimated_minutes, s.status, s.checkpoint_note, s.last_checked_at, s.completed_at, s.created_at"
|
||||
|
||||
|
||||
class CreateStepRequest(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
estimated_minutes: int | None = None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/steps", response_model=StepOut)
|
||||
async def create_step(task_id: UUID, req: CreateStepRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
task = await pool.fetchrow("SELECT id FROM tasks WHERE id = $1 AND user_id = $2::uuid", task_id, user_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Place new step at the end
|
||||
max_order = await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) FROM steps WHERE task_id = $1", task_id)
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO steps (task_id, sort_order, title, description, estimated_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, task_id, sort_order, title, description, estimated_minutes,
|
||||
status, checkpoint_note, last_checked_at, completed_at, created_at""",
|
||||
task_id,
|
||||
max_order + 1,
|
||||
req.title,
|
||||
req.description,
|
||||
req.estimated_minutes,
|
||||
)
|
||||
return StepOut(**dict(row))
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/steps", response_model=list[StepOut])
|
||||
async def list_steps(task_id: UUID, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Verify task belongs to user
|
||||
task = await pool.fetchrow("SELECT id FROM tasks WHERE id = $1 AND user_id = $2::uuid", task_id, user_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
rows = await pool.fetch(
|
||||
f"SELECT {STEP_COLUMNS} FROM steps s WHERE s.task_id = $1 ORDER BY s.sort_order",
|
||||
task_id,
|
||||
)
|
||||
return [StepOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.patch("/steps/{step_id}", response_model=StepOut)
|
||||
async def update_step(step_id: UUID, req: StepUpdate, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
# Verify step belongs to user's task
|
||||
step = await pool.fetchrow(
|
||||
"""SELECT s.id FROM steps s
|
||||
JOIN tasks t ON s.task_id = t.id
|
||||
WHERE s.id = $1 AND t.user_id = $2::uuid""",
|
||||
step_id,
|
||||
user_id,
|
||||
)
|
||||
if not step:
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
fields = []
|
||||
values = []
|
||||
idx = 2 # $1 = step_id
|
||||
|
||||
update_data = req.model_dump(exclude_unset=True)
|
||||
for key, val in update_data.items():
|
||||
fields.append(f"{key} = ${idx}")
|
||||
values.append(val)
|
||||
idx += 1
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
set_clause = ", ".join(fields)
|
||||
row = await pool.fetchrow(
|
||||
f"""UPDATE steps SET {set_clause}
|
||||
WHERE id = $1
|
||||
RETURNING id, task_id, sort_order, title, description, estimated_minutes,
|
||||
status, checkpoint_note, last_checked_at, completed_at, created_at""",
|
||||
step_id,
|
||||
*values,
|
||||
)
|
||||
return StepOut(**dict(row))
|
||||
|
||||
|
||||
@router.post("/steps/{step_id}/complete", response_model=StepOut)
|
||||
async def complete_step(step_id: UUID, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
step = await pool.fetchrow(
|
||||
"""SELECT s.id FROM steps s
|
||||
JOIN tasks t ON s.task_id = t.id
|
||||
WHERE s.id = $1 AND t.user_id = $2::uuid""",
|
||||
step_id,
|
||||
user_id,
|
||||
)
|
||||
if not step:
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""UPDATE steps SET status = 'done', completed_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, task_id, sort_order, title, description, estimated_minutes,
|
||||
status, checkpoint_note, last_checked_at, completed_at, created_at""",
|
||||
step_id,
|
||||
)
|
||||
return StepOut(**dict(row))
|
||||
|
||||
|
||||
@router.delete("/steps/{step_id}", status_code=204)
|
||||
async def delete_step(step_id: UUID, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
step = await pool.fetchrow(
|
||||
"""SELECT s.id FROM steps s
|
||||
JOIN tasks t ON s.task_id = t.id
|
||||
WHERE s.id = $1 AND t.user_id = $2::uuid""",
|
||||
step_id,
|
||||
user_id,
|
||||
)
|
||||
if not step:
|
||||
raise HTTPException(status_code=404, detail="Step not found")
|
||||
|
||||
await pool.execute("DELETE FROM steps WHERE id = $1", step_id)
|
||||
298
app/routers/tasks.py
Normal file
298
app/routers/tasks.py
Normal file
@@ -0,0 +1,298 @@
|
||||
from datetime import datetime as dt
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.middleware.auth import get_current_user_id
|
||||
from app.models import (
|
||||
BrainDumpRequest,
|
||||
BrainDumpResponse,
|
||||
PlanRequest,
|
||||
PlanResponse,
|
||||
StepOut,
|
||||
TaskCreate,
|
||||
TaskOut,
|
||||
TaskUpdate,
|
||||
)
|
||||
from app.services import llm, push
|
||||
from app.services.db import get_pool
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
def _row_to_task(row) -> TaskOut:
|
||||
return TaskOut(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
priority=row["priority"],
|
||||
status=row["status"],
|
||||
deadline=row["deadline"],
|
||||
estimated_minutes=row["estimated_minutes"],
|
||||
source=row["source"],
|
||||
tags=row["tags"] or [],
|
||||
plan_type=row["plan_type"],
|
||||
brain_dump_raw=row["brain_dump_raw"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
TASK_COLUMNS = "id, user_id, title, description, priority, status, deadline, estimated_minutes, source, tags, plan_type, brain_dump_raw, created_at, updated_at"
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
status: str | None = None,
|
||||
priority: int | None = None,
|
||||
sort_by: str = Query("priority", pattern="^(priority|deadline|created_at)$"),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
pool = await get_pool()
|
||||
|
||||
query = f"SELECT {TASK_COLUMNS} FROM tasks WHERE user_id = $1::uuid AND status != 'deferred'"
|
||||
params: list = [user_id]
|
||||
idx = 2
|
||||
|
||||
if status:
|
||||
query += f" AND status = ${idx}"
|
||||
params.append(status)
|
||||
idx += 1
|
||||
if priority is not None:
|
||||
query += f" AND priority = ${idx}"
|
||||
params.append(priority)
|
||||
idx += 1
|
||||
|
||||
sort_dir = "DESC" if sort_by == "priority" else "ASC"
|
||||
query += f" ORDER BY {sort_by} {sort_dir}"
|
||||
|
||||
rows = await pool.fetch(query, *params)
|
||||
return [_row_to_task(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/upcoming", response_model=list[TaskOut])
|
||||
async def upcoming_tasks(user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
f"""SELECT {TASK_COLUMNS} FROM tasks
|
||||
WHERE user_id = $1::uuid AND deadline IS NOT NULL
|
||||
AND deadline <= now() + interval '48 hours'
|
||||
AND status NOT IN ('done', 'deferred')
|
||||
ORDER BY deadline ASC""",
|
||||
user_id,
|
||||
)
|
||||
return [_row_to_task(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
async def create_task(req: TaskCreate, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
f"""INSERT INTO tasks (user_id, title, description, priority, deadline, estimated_minutes, tags)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING {TASK_COLUMNS}""",
|
||||
user_id,
|
||||
req.title,
|
||||
req.description,
|
||||
req.priority,
|
||||
req.deadline,
|
||||
req.estimated_minutes,
|
||||
req.tags,
|
||||
)
|
||||
await push.send_task_added(user_id, row["title"], step_count=0)
|
||||
return _row_to_task(row)
|
||||
|
||||
|
||||
@router.post("/brain-dump", response_model=BrainDumpResponse)
|
||||
async def brain_dump(req: BrainDumpRequest, user_id: str = Depends(get_current_user_id)):
|
||||
result = await llm.parse_brain_dump(req.raw_text, req.timezone)
|
||||
|
||||
pool = await get_pool()
|
||||
parsed_tasks = []
|
||||
for t in result.get("parsed_tasks", []):
|
||||
# Parse deadline string from LLM into datetime (asyncpg needs datetime, not str)
|
||||
deadline = t.get("deadline")
|
||||
if isinstance(deadline, str) and deadline and deadline != "null":
|
||||
try:
|
||||
deadline = dt.fromisoformat(deadline)
|
||||
except ValueError:
|
||||
deadline = None
|
||||
else:
|
||||
deadline = None
|
||||
|
||||
est_minutes = t.get("estimated_minutes")
|
||||
if isinstance(est_minutes, str):
|
||||
try:
|
||||
est_minutes = int(est_minutes)
|
||||
except ValueError:
|
||||
est_minutes = None
|
||||
|
||||
subtasks_raw = t.get("subtasks") or []
|
||||
has_subtasks = len(subtasks_raw) > 0
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""INSERT INTO tasks (user_id, title, description, priority, deadline,
|
||||
estimated_minutes, source, tags, brain_dump_raw, plan_type)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5::timestamptz, $6, $7, $8, $9, $10)
|
||||
RETURNING {TASK_COLUMNS}""",
|
||||
user_id,
|
||||
t["title"],
|
||||
t.get("description"),
|
||||
int(t.get("priority", 0)),
|
||||
deadline,
|
||||
est_minutes,
|
||||
req.source,
|
||||
t.get("tags", []),
|
||||
req.raw_text,
|
||||
"brain_dump" if has_subtasks else None,
|
||||
)
|
||||
task_id = row["id"]
|
||||
|
||||
all_subtasks = []
|
||||
sort_order = 1
|
||||
for sub in subtasks_raw:
|
||||
sub_est = sub.get("estimated_minutes")
|
||||
if isinstance(sub_est, str):
|
||||
try:
|
||||
sub_est = int(sub_est)
|
||||
except ValueError:
|
||||
sub_est = None
|
||||
|
||||
sub_deadline = sub.get("deadline")
|
||||
if isinstance(sub_deadline, str) and sub_deadline and sub_deadline != "null":
|
||||
try:
|
||||
sub_deadline = dt.fromisoformat(sub_deadline)
|
||||
except ValueError:
|
||||
sub_deadline = None
|
||||
else:
|
||||
sub_deadline = None
|
||||
|
||||
is_suggested = bool(sub.get("suggested", False))
|
||||
|
||||
# Only save non-suggested steps now; suggested ones are opt-in from the client
|
||||
if not is_suggested:
|
||||
await pool.fetchrow(
|
||||
"""INSERT INTO steps (task_id, sort_order, title, description, estimated_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id""",
|
||||
task_id,
|
||||
sort_order,
|
||||
sub["title"],
|
||||
sub.get("description"),
|
||||
sub_est,
|
||||
)
|
||||
sort_order += 1
|
||||
|
||||
all_subtasks.append({
|
||||
"title": sub["title"],
|
||||
"description": sub.get("description"),
|
||||
"deadline": sub_deadline.isoformat() if sub_deadline else None,
|
||||
"estimated_minutes": sub_est,
|
||||
"suggested": is_suggested,
|
||||
})
|
||||
|
||||
saved_count = sum(1 for s in all_subtasks if not s["suggested"])
|
||||
await push.send_task_added(user_id, row["title"], step_count=saved_count)
|
||||
|
||||
parsed_tasks.append({
|
||||
"task_id": str(row["id"]),
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"priority": row["priority"],
|
||||
"deadline": row["deadline"],
|
||||
"estimated_minutes": row["estimated_minutes"],
|
||||
"tags": row["tags"] or [],
|
||||
"subtasks": all_subtasks,
|
||||
})
|
||||
|
||||
return BrainDumpResponse(
|
||||
parsed_tasks=parsed_tasks,
|
||||
unparseable_fragments=result.get("unparseable_fragments", []),
|
||||
ask_for_plans=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/plan", response_model=PlanResponse)
|
||||
async def plan_task(task_id: UUID, req: PlanRequest, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
task = await pool.fetchrow(
|
||||
"SELECT id, title, description, estimated_minutes FROM tasks WHERE id = $1 AND user_id = $2::uuid",
|
||||
task_id,
|
||||
user_id,
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
steps_data = await llm.generate_step_plan(task["title"], task["description"], task["estimated_minutes"])
|
||||
|
||||
steps = []
|
||||
for s in steps_data:
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO steps (task_id, sort_order, title, description, estimated_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, task_id, sort_order, title, description, estimated_minutes,
|
||||
status, checkpoint_note, last_checked_at, completed_at, created_at""",
|
||||
task_id,
|
||||
s["sort_order"],
|
||||
s["title"],
|
||||
s.get("description"),
|
||||
s.get("estimated_minutes"),
|
||||
)
|
||||
steps.append(StepOut(**dict(row)))
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE tasks SET plan_type = $1, status = 'ready', updated_at = now() WHERE id = $2",
|
||||
req.plan_type,
|
||||
task_id,
|
||||
)
|
||||
await push.send_task_added(user_id, task["title"], step_count=len(steps))
|
||||
|
||||
return PlanResponse(task_id=task_id, plan_type=req.plan_type, steps=steps)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskOut)
|
||||
async def update_task(task_id: UUID, req: TaskUpdate, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
|
||||
fields = []
|
||||
values = []
|
||||
idx = 3 # $1 = task_id, $2 = user_id
|
||||
|
||||
update_data = req.model_dump(exclude_unset=True)
|
||||
for key, val in update_data.items():
|
||||
fields.append(f"{key} = ${idx}")
|
||||
values.append(val)
|
||||
idx += 1
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
fields.append("updated_at = now()")
|
||||
set_clause = ", ".join(fields)
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""UPDATE tasks SET {set_clause}
|
||||
WHERE id = $1 AND user_id = $2::uuid
|
||||
RETURNING {TASK_COLUMNS}""",
|
||||
task_id,
|
||||
user_id,
|
||||
*values,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
return _row_to_task(row)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
async def delete_task(task_id: UUID, user_id: str = Depends(get_current_user_id)):
|
||||
pool = await get_pool()
|
||||
result = await pool.execute(
|
||||
"UPDATE tasks SET status = 'deferred', updated_at = now() WHERE id = $1 AND user_id = $2::uuid",
|
||||
task_id,
|
||||
user_id,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
Reference in New Issue
Block a user