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

0
app/routers/__init__.py Normal file
View File

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

96
app/routers/proactive.py Normal file
View 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
View 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
View 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
View 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")