This commit is contained in:
2026-03-29 06:57:34 -04:00
commit 37503231b3
31 changed files with 3444 additions and 0 deletions

308
app/routers/distractions.py Normal file
View 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,
)