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, )