309 lines
11 KiB
Python
309 lines
11 KiB
Python
|
|
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,
|
||
|
|
)
|