API
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.p8
|
||||
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What This Is
|
||||
|
||||
LockInBro API — the FastAPI backend for an ADHD-aware productivity system. Runs on a DigitalOcean droplet (1GB RAM) behind nginx with SSL at `https://wahwa.com/api/v1`. PostgreSQL database `focusapp` on the same droplet.
|
||||
|
||||
## Design Doc (Source of Truth)
|
||||
|
||||
**Always consult `/home/devuser/.github/profile/README.md` before making architectural decisions.** This is the full technical design document. Keep it up to date by pulling/pushing, but avoid frequent changes — batch updates when possible.
|
||||
|
||||
## Related Repositories
|
||||
|
||||
- `/home/devuser/BlindMaster/blinds_express` — Previous Express.js project on the same droplet. Contains **server-side auth workflow** (Argon2 + JWT) used as scaffolding reference for this project's auth module.
|
||||
- `/home/devuser/BlindMaster/blinds_flutter` — Flutter app with **app-side auth flow** being ported to iOS/SwiftUI.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python / FastAPI** on port 3000, served by uvicorn
|
||||
- **PostgreSQL** on port 5432, database `focusapp`, `root` superuser
|
||||
- **Auth:** Argon2id (email/password) + Apple Sign In + JWT (stateless)
|
||||
- **AI:** Claude API or Gemini API (auto-selects based on which key is set). Brain-dump parsing, VLM screenshot analysis, step planning, context resume
|
||||
- **Analytics:** Hex API (notebooks query Postgres directly)
|
||||
- **nginx** reverse-proxies `wahwa.com` → `localhost:3000`, WebSocket support enabled, SSL via Certbot
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Activate conda environment (must do first)
|
||||
source ~/miniconda3/bin/activate && conda activate lockinbro
|
||||
|
||||
# Run the server
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 3000 --reload
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Export environment after adding packages
|
||||
conda env export --no-builds > environment.yml
|
||||
pip freeze > requirements.txt
|
||||
|
||||
# Database migrations (requires Postgres connection)
|
||||
alembic upgrade head # apply migrations
|
||||
alembic revision -m "description" # create new migration (manual SQL in upgrade/downgrade)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI entry point
|
||||
│ ├── config.py # Settings from env vars
|
||||
│ ├── middleware/auth.py # JWT validation + Argon2 utils
|
||||
│ ├── routers/ # auth, tasks, steps, sessions, distractions, analytics
|
||||
│ ├── services/
|
||||
│ │ ├── llm.py # All Claude API calls + prompt templates
|
||||
│ │ ├── hex_service.py # Hex notebook trigger + poll
|
||||
│ │ └── db.py # asyncpg Postgres client
|
||||
│ ├── models.py # Pydantic request/response schemas
|
||||
│ └── types.py
|
||||
├── alembic/
|
||||
├── requirements.txt
|
||||
└── .env
|
||||
```
|
||||
|
||||
### Key Data Flows
|
||||
|
||||
1. **Brain-dump parsing:** iOS sends raw text → `POST /tasks/brain-dump` → Claude extracts structured tasks → optionally `POST /tasks/{id}/plan` → Claude generates 5-15 min ADHD-friendly steps
|
||||
2. **Distraction detection:** macOS sends screenshot (raw JPEG binary via multipart) + task context → `POST /distractions/analyze-screenshot` → Claude Vision analyzes → backend auto-updates step statuses + writes `checkpoint_note` → returns nudge if distracted (confidence > 0.7)
|
||||
3. **Context resume:** `GET /sessions/{id}/resume` → uses `checkpoint_note` to generate hyper-specific "welcome back" card
|
||||
4. **Analytics:** Backend writes events to Postgres → Hex notebooks query directly → results served via `/analytics/*` endpoints
|
||||
|
||||
### Critical Design Decisions
|
||||
|
||||
- **Steps are a separate table, not JSONB** — VLM updates individual step statuses every ~20s. Separate rows avoid read-modify-write races.
|
||||
- **No dynamic step splitting** — Growing a task list mid-work causes ADHD decision paralysis. Use `checkpoint_note` for within-step progress instead.
|
||||
- **Screenshots are never persisted** — base64 in-memory only, discarded after VLM response. Privacy-critical.
|
||||
- **Step auto-update is a backend side-effect** — `/analyze-screenshot` applies step changes server-side before responding. Client doesn't make separate step-update calls.
|
||||
- **1GB RAM constraint** — Limit concurrent VLM requests to 1-2 in-flight. FastAPI + uvicorn uses ~40-60MB.
|
||||
|
||||
### AI/LLM Guidelines
|
||||
|
||||
All AI calls go through `services/llm.py`. Key principles:
|
||||
- Non-judgmental tone (never "you got distracted again")
|
||||
- Concise, scannable outputs (ADHD working memory)
|
||||
- All AI responses are structured JSON
|
||||
- Every prompt includes full task + step context
|
||||
- Graceful degradation if Claude API is down
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://devuser@/focusapp
|
||||
JWT_SECRET=<secret>
|
||||
ANTHROPIC_API_KEY=<key> # set one of these two
|
||||
GEMINI_API_KEY=<key> # prefers Anthropic if both set
|
||||
HEX_API_TOKEN=<token>
|
||||
HEX_NB_DISTRACTIONS=<notebook_id>
|
||||
HEX_NB_FOCUS_TRENDS=<notebook_id>
|
||||
HEX_NB_WEEKLY_REPORT=<notebook_id>
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Run Claude Code from local machine over SSH, not on the droplet (saves RAM/disk)
|
||||
- The droplet hostname is `blindmaster-ubuntu`
|
||||
- PM2 is killed, `blinds_express` is stopped, port 3000 is free for this project
|
||||
149
alembic.ini
Normal file
149
alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql://devuser@/focusapp
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
78
alembic/env.py
Normal file
78
alembic/env.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = None
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
116
alembic/versions/001_initial_schema.py
Normal file
116
alembic/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS public.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
apple_user_id TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
timezone TEXT DEFAULT 'America/Chicago',
|
||||
distraction_apps TEXT[] DEFAULT '{}',
|
||||
preferences JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT auth_method CHECK (email IS NOT NULL OR apple_user_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
priority INT DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
deadline TIMESTAMPTZ,
|
||||
estimated_minutes INT,
|
||||
source TEXT DEFAULT 'manual',
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
plan_type TEXT,
|
||||
brain_dump_raw TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task_id UUID NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
estimated_minutes INT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
checkpoint_note TEXT,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
task_id UUID REFERENCES public.tasks(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
status TEXT DEFAULT 'active',
|
||||
checkpoint JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.distractions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
session_id UUID REFERENCES public.sessions(id) ON DELETE SET NULL,
|
||||
detected_at TIMESTAMPTZ DEFAULT now(),
|
||||
distraction_type TEXT,
|
||||
app_name TEXT,
|
||||
duration_seconds INT,
|
||||
confidence FLOAT,
|
||||
vlm_summary TEXT,
|
||||
nudge_shown BOOLEAN DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.distraction_patterns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
pattern_type TEXT,
|
||||
description TEXT,
|
||||
frequency INT DEFAULT 1,
|
||||
last_seen TIMESTAMPTZ DEFAULT now(),
|
||||
metadata JSONB DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_user ON tasks(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_steps_task ON steps(task_id, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_steps_status ON steps(task_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(user_id, status) WHERE status = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_distractions_user ON distractions(user_id, detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_distractions_app ON distractions(user_id, app_name, detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_distractions_hourly ON distractions(user_id, EXTRACT(HOUR FROM detected_at AT TIME ZONE 'UTC'));
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("""
|
||||
DROP TABLE IF EXISTS public.distraction_patterns CASCADE;
|
||||
DROP TABLE IF EXISTS public.distractions CASCADE;
|
||||
DROP TABLE IF EXISTS public.sessions CASCADE;
|
||||
DROP TABLE IF EXISTS public.steps CASCADE;
|
||||
DROP TABLE IF EXISTS public.tasks CASCADE;
|
||||
DROP TABLE IF EXISTS public.users CASCADE;
|
||||
""")
|
||||
29
alembic/versions/002_cross_device_handoff.py
Normal file
29
alembic/versions/002_cross_device_handoff.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Cross-device handoff: device_tokens + session platform
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "002"
|
||||
down_revision = "001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS device_tokens JSONB DEFAULT '[]';
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform TEXT DEFAULT 'mac';
|
||||
ALTER TABLE sessions ALTER COLUMN platform SET NOT NULL;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("""
|
||||
ALTER TABLE sessions DROP COLUMN IF EXISTS platform;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS device_tokens;
|
||||
""")
|
||||
36
alembic/versions/003_proactive_actions.py
Normal file
36
alembic/versions/003_proactive_actions.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Proactive actions table for Argus layer
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "003"
|
||||
down_revision = "002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS public.proactive_actions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
session_id UUID REFERENCES public.sessions(id) ON DELETE SET NULL,
|
||||
friction_type TEXT NOT NULL,
|
||||
proposed_action TEXT NOT NULL,
|
||||
user_choice TEXT,
|
||||
chosen_action TEXT,
|
||||
executed BOOLEAN DEFAULT false,
|
||||
detected_at TIMESTAMPTZ DEFAULT now(),
|
||||
responded_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_proactive_user ON proactive_actions(user_id, friction_type);
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP TABLE IF EXISTS public.proactive_actions CASCADE;")
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
25
app/config.py
Normal file
25
app/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql://root@localhost:5432/focusapp"
|
||||
JWT_SECRET: str = "change-me"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_EXPIRE_MINUTES: int = 60
|
||||
JWT_REFRESH_EXPIRE_DAYS: int = 30
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
GEMINI_API_KEY: str = ""
|
||||
HEX_API_TOKEN: str = ""
|
||||
HEX_NB_DISTRACTIONS: str = ""
|
||||
HEX_NB_FOCUS_TRENDS: str = ""
|
||||
HEX_NB_WEEKLY_REPORT: str = ""
|
||||
APPLE_BUNDLE_ID: str = "com.adipu.LockInBroMobile"
|
||||
APNS_KEY_ID: str = ""
|
||||
APNS_TEAM_ID: str = ""
|
||||
APNS_P8_PATH: str = "" # path to the .p8 file on disk
|
||||
APNS_SANDBOX: bool = False # True for dev/TestFlight, False for App Store
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
65
app/main.py
Normal file
65
app/main.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import analytics, auth, distractions, proactive, sessions, steps, tasks
|
||||
from app.services.db import close_pool, get_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await get_pool()
|
||||
print(f"APNs config → KEY_ID={settings.APNS_KEY_ID or '(empty)'!r} "
|
||||
f"TEAM_ID={settings.APNS_TEAM_ID or '(empty)'!r} "
|
||||
f"P8_PATH={settings.APNS_P8_PATH or '(empty)'!r} "
|
||||
f"SANDBOX={settings.APNS_SANDBOX} "
|
||||
f"BUNDLE={settings.APPLE_BUNDLE_ID}")
|
||||
yield
|
||||
await close_pool()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LockInBro API",
|
||||
version="1.0.0",
|
||||
root_path="/api/v1",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def llm_error_handler(request: Request, exc: Exception):
|
||||
# Surface LLM provider errors as 502 instead of 500
|
||||
exc_name = type(exc).__name__
|
||||
if "ClientError" in exc_name or "APIError" in exc_name or "APIConnectionError" in exc_name:
|
||||
return JSONResponse(status_code=502, content={"detail": f"LLM provider error: {exc}"})
|
||||
if isinstance(exc, RuntimeError) and "No LLM API key" in str(exc):
|
||||
return JSONResponse(status_code=503, content={"detail": str(exc)})
|
||||
raise exc
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_client_info(request: Request, call_next):
|
||||
real_ip = request.headers.get("cf-connecting-ip", request.headers.get("x-forwarded-for", "unknown"))
|
||||
ua = request.headers.get("user-agent", "unknown")
|
||||
response = await call_next(request)
|
||||
if request.url.path != "/api/v1/health":
|
||||
print(f"[REQ] {request.method} {request.url.path} → {response.status_code} | ip={real_ip} ua={ua[:80]}")
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(steps.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(distractions.router)
|
||||
app.include_router(proactive.router)
|
||||
app.include_router(analytics.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
0
app/middleware/__init__.py
Normal file
0
app/middleware/__init__.py
Normal file
67
app/middleware/auth.py
Normal file
67
app/middleware/auth.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
ph = PasswordHasher()
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return ph.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return ph.verify(hashed, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.JWT_ACCESS_EXPIRE_MINUTES),
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(days=settings.JWT_REFRESH_EXPIRE_DAYS),
|
||||
"type": "refresh",
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = "access") -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
if payload.get("type") != expected_type:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
return payload
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
) -> str:
|
||||
payload = decode_token(credentials.credentials, expected_type="access")
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return user_id
|
||||
368
app/models.py
Normal file
368
app/models.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
# ── Auth ──
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
display_name: str | None = None
|
||||
timezone: str = "America/Chicago"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class AppleAuthRequest(BaseModel):
|
||||
identity_token: str
|
||||
authorization_code: str
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
user: "UserOut"
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: UUID
|
||||
email: str | None
|
||||
display_name: str | None
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ── Tasks ──
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
priority: int = Field(0, ge=0, le=4)
|
||||
deadline: datetime | None = None
|
||||
estimated_minutes: int | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
priority: int | None = Field(None, ge=0, le=4)
|
||||
status: str | None = None
|
||||
deadline: datetime | None = None
|
||||
estimated_minutes: int | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: str | None
|
||||
priority: int
|
||||
status: str
|
||||
deadline: datetime | None
|
||||
estimated_minutes: int | None
|
||||
source: str
|
||||
tags: list[str]
|
||||
plan_type: str | None
|
||||
brain_dump_raw: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BrainDumpRequest(BaseModel):
|
||||
raw_text: str
|
||||
source: str = "manual"
|
||||
timezone: str = "America/Chicago"
|
||||
|
||||
|
||||
class ParsedSubtask(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
deadline: datetime | None = None
|
||||
estimated_minutes: int | None = None
|
||||
suggested: bool = False
|
||||
|
||||
|
||||
class ParsedTask(BaseModel):
|
||||
task_id: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
priority: int = 0
|
||||
deadline: datetime | None = None
|
||||
estimated_minutes: int | None = None
|
||||
tags: list[str] = []
|
||||
subtasks: list[ParsedSubtask] = []
|
||||
|
||||
class BrainDumpResponse(BaseModel):
|
||||
parsed_tasks: list[ParsedTask]
|
||||
unparseable_fragments: list[str] = []
|
||||
ask_for_plans: bool = True
|
||||
|
||||
|
||||
class PlanRequest(BaseModel):
|
||||
plan_type: str = "llm_generated"
|
||||
|
||||
|
||||
# ── Steps ──
|
||||
|
||||
|
||||
class StepOut(BaseModel):
|
||||
id: UUID
|
||||
task_id: UUID
|
||||
sort_order: int
|
||||
title: str
|
||||
description: str | None
|
||||
estimated_minutes: int | None
|
||||
status: str
|
||||
checkpoint_note: str | None
|
||||
last_checked_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StepUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
estimated_minutes: int | None = None
|
||||
sort_order: int | None = None
|
||||
status: str | None = None
|
||||
checkpoint_note: str | None = None
|
||||
|
||||
|
||||
class PlanResponse(BaseModel):
|
||||
task_id: UUID
|
||||
plan_type: str
|
||||
steps: list[StepOut]
|
||||
|
||||
|
||||
# ── Sessions ──
|
||||
|
||||
|
||||
class SessionStartRequest(BaseModel):
|
||||
task_id: UUID | None = None
|
||||
platform: str = "mac"
|
||||
work_app_bundle_ids: list[str] | None = None
|
||||
|
||||
|
||||
class SessionCheckpointRequest(BaseModel):
|
||||
current_step_id: UUID | None = None
|
||||
last_action_summary: str | None = None
|
||||
next_up: str | None = None
|
||||
goal: str | None = None
|
||||
active_app: str | None = None
|
||||
last_screenshot_analysis: str | None = None
|
||||
attention_score: int | None = None
|
||||
distraction_count: int | None = None
|
||||
|
||||
|
||||
class SessionEndRequest(BaseModel):
|
||||
status: str = "completed"
|
||||
|
||||
|
||||
class OpenSessionOut(BaseModel):
|
||||
id: UUID
|
||||
task_id: UUID | None
|
||||
task: dict | None
|
||||
status: str
|
||||
platform: str
|
||||
started_at: datetime
|
||||
ended_at: datetime | None
|
||||
checkpoint: dict
|
||||
|
||||
|
||||
class SessionJoinRequest(BaseModel):
|
||||
platform: str = "ipad"
|
||||
work_app_bundle_ids: list[str] | None = None
|
||||
|
||||
|
||||
class SessionJoinResponse(BaseModel):
|
||||
session_id: UUID
|
||||
joined: bool
|
||||
task: dict | None
|
||||
current_step: dict | None
|
||||
all_steps: list[StepOut]
|
||||
suggested_app_scheme: str | None
|
||||
suggested_app_name: str | None
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
task_id: UUID | None
|
||||
platform: str
|
||||
started_at: datetime
|
||||
ended_at: datetime | None
|
||||
status: str
|
||||
checkpoint: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ResumeCard(BaseModel):
|
||||
welcome_back: str
|
||||
you_were_doing: str
|
||||
next_step: str
|
||||
motivation: str
|
||||
|
||||
|
||||
class SessionResumeResponse(BaseModel):
|
||||
session_id: UUID
|
||||
task: dict | None
|
||||
current_step: dict | None
|
||||
progress: dict
|
||||
resume_card: ResumeCard
|
||||
|
||||
|
||||
# ── Distractions ──
|
||||
|
||||
|
||||
class ScreenshotAnalysisResponse(BaseModel):
|
||||
on_task: bool
|
||||
current_step_id: UUID | None
|
||||
checkpoint_note_update: str | None
|
||||
steps_completed: list[UUID] = []
|
||||
distraction_type: str | None
|
||||
app_name: str | None
|
||||
confidence: float
|
||||
gentle_nudge: str | None
|
||||
vlm_summary: str | None
|
||||
|
||||
|
||||
class AppActivityRequest(BaseModel):
|
||||
session_id: UUID
|
||||
app_bundle_id: str
|
||||
app_name: str
|
||||
duration_seconds: int
|
||||
returned_to_task: bool = False
|
||||
|
||||
|
||||
class AppActivityResponse(BaseModel):
|
||||
distraction_logged: bool
|
||||
session_distraction_count: int
|
||||
gentle_nudge: str | None
|
||||
|
||||
|
||||
class AppCheckRequest(BaseModel):
|
||||
app_bundle_id: str
|
||||
|
||||
|
||||
class AppCheckResponse(BaseModel):
|
||||
is_distraction_app: bool
|
||||
pending_task_count: int
|
||||
most_urgent_task: dict | None
|
||||
nudge: str | None
|
||||
|
||||
|
||||
# ── Analyze Result (Device-Side VLM) ──
|
||||
|
||||
|
||||
class ProposedAction(BaseModel):
|
||||
label: str
|
||||
action_type: str
|
||||
details: str | None = None
|
||||
|
||||
|
||||
class FrictionDetection(BaseModel):
|
||||
type: str = "none"
|
||||
confidence: float = 0.0
|
||||
description: str | None = None
|
||||
proposed_actions: list[ProposedAction] = []
|
||||
source_context: str | None = None
|
||||
target_context: str | None = None
|
||||
|
||||
|
||||
class SessionAction(BaseModel):
|
||||
type: str = "none" # resume | switch | complete | start_new | none
|
||||
session_id: UUID | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class AnalyzeResultRequest(BaseModel):
|
||||
session_id: UUID | None = None # optional — VLM can run without an active session
|
||||
on_task: bool
|
||||
current_step_id: UUID | None = None
|
||||
inferred_task: str | None = None
|
||||
checkpoint_note_update: str | None = None
|
||||
steps_completed: list[UUID] = []
|
||||
friction: FrictionDetection = FrictionDetection()
|
||||
session_action: SessionAction = SessionAction()
|
||||
intent: str | None = None
|
||||
distraction_type: str | None = None
|
||||
app_name: str | None = None
|
||||
confidence: float = 0.0
|
||||
gentle_nudge: str | None = None
|
||||
vlm_summary: str | None = None
|
||||
|
||||
|
||||
class AnalyzeResultResponse(BaseModel):
|
||||
side_effects_applied: bool
|
||||
steps_updated: int
|
||||
distraction_logged: bool
|
||||
proactive_action_id: UUID | None = None
|
||||
|
||||
|
||||
# ── Proactive Actions ──
|
||||
|
||||
|
||||
class ProactiveRespondRequest(BaseModel):
|
||||
proactive_action_id: UUID
|
||||
user_choice: str # accepted | declined | alternative_chosen
|
||||
chosen_action: str | None = None
|
||||
|
||||
|
||||
class ProactiveRespondResponse(BaseModel):
|
||||
logged: bool
|
||||
should_execute: bool
|
||||
|
||||
|
||||
class ProactiveExecuteRequest(BaseModel):
|
||||
proactive_action_id: UUID
|
||||
action_type: str
|
||||
execution_params: dict = {}
|
||||
|
||||
|
||||
class ProactiveExecuteResponse(BaseModel):
|
||||
executed: bool
|
||||
result: str | None = None
|
||||
|
||||
|
||||
class ProactivePreference(BaseModel):
|
||||
preferred_action: str | None
|
||||
total_choices: int
|
||||
acceptance_rate: float
|
||||
|
||||
|
||||
class ProactivePreferencesResponse(BaseModel):
|
||||
preferences: dict[str, ProactivePreference]
|
||||
|
||||
|
||||
# ── Device Token ──
|
||||
|
||||
|
||||
class DeviceTokenRequest(BaseModel):
|
||||
platform: str
|
||||
token: str
|
||||
|
||||
|
||||
# ── Analytics ──
|
||||
|
||||
|
||||
class AnalyticsSummary(BaseModel):
|
||||
total_focus_minutes: float
|
||||
sessions_completed: int
|
||||
tasks_completed: int
|
||||
top_distractors: list[dict]
|
||||
avg_attention_score: float | None
|
||||
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")
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
18
app/services/db.py
Normal file
18
app/services/db.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import asyncpg
|
||||
from app.config import settings
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
global pool
|
||||
if pool is None:
|
||||
pool = await asyncpg.create_pool(settings.DATABASE_URL, min_size=2, max_size=10)
|
||||
return pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
global pool
|
||||
if pool:
|
||||
await pool.close()
|
||||
pool = None
|
||||
49
app/services/hex_service.py
Normal file
49
app/services/hex_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
HEX_API_BASE = "https://app.hex.tech/api/v1"
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {settings.HEX_API_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
NOTEBOOKS = {
|
||||
"distraction_patterns": settings.HEX_NB_DISTRACTIONS,
|
||||
"focus_trends": settings.HEX_NB_FOCUS_TRENDS,
|
||||
"weekly_report": settings.HEX_NB_WEEKLY_REPORT,
|
||||
}
|
||||
|
||||
|
||||
async def run_notebook(notebook_key: str, user_id: str) -> dict:
|
||||
project_id = NOTEBOOKS.get(notebook_key)
|
||||
if not project_id:
|
||||
raise ValueError(f"Unknown notebook: {notebook_key}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as http:
|
||||
# Trigger run — POST /projects/{projectId}/runs
|
||||
resp = await http.post(
|
||||
f"{HEX_API_BASE}/projects/{project_id}/runs",
|
||||
headers=HEADERS,
|
||||
json={"inputParams": {"user_id": user_id}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
run_id = resp.json()["runId"]
|
||||
|
||||
# Poll for completion — GET /projects/{projectId}/runs/{runId}
|
||||
for _ in range(30):
|
||||
status_resp = await http.get(
|
||||
f"{HEX_API_BASE}/projects/{project_id}/runs/{run_id}",
|
||||
headers=HEADERS,
|
||||
)
|
||||
status_resp.raise_for_status()
|
||||
data = status_resp.json()
|
||||
if data["status"] == "COMPLETED":
|
||||
return {"status": "COMPLETED", "elapsed": data.get("elapsedTime")}
|
||||
if data["status"] in ("ERRORED", "KILLED", "UNABLE_TO_ALLOCATE_KERNEL"):
|
||||
raise RuntimeError(f"Hex run failed: {data['status']}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
raise TimeoutError("Hex notebook run timed out")
|
||||
351
app/services/llm.py
Normal file
351
app/services/llm.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Provider setup: prefer Anthropic, fall back to Gemini ──
|
||||
|
||||
_provider: str | None = None
|
||||
|
||||
if settings.ANTHROPIC_API_KEY:
|
||||
import anthropic
|
||||
_anthropic_client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
_provider = "anthropic"
|
||||
_model = "claude-sonnet-4-20250514"
|
||||
logger.info("LLM provider: Anthropic (Claude)")
|
||||
|
||||
elif settings.GEMINI_API_KEY:
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
_gemini_client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
||||
_provider = "gemini"
|
||||
_model = "gemini-3.1-pro-preview"
|
||||
logger.info("LLM provider: Google (Gemini)")
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict | list:
|
||||
import re
|
||||
text = text.strip()
|
||||
# Strip markdown code fences
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1]
|
||||
text = text.rsplit("```", 1)[0]
|
||||
# Find the first { or [ and last } or ]
|
||||
start = -1
|
||||
for i, c in enumerate(text):
|
||||
if c in "{[":
|
||||
start = i
|
||||
break
|
||||
if start == -1:
|
||||
raise ValueError(f"No JSON found in LLM response: {text[:200]}")
|
||||
end = max(text.rfind("}"), text.rfind("]"))
|
||||
if end == -1:
|
||||
raise ValueError(f"No closing bracket in LLM response: {text[:200]}")
|
||||
json_str = text[start:end + 1]
|
||||
# Strip // comments (Gemini sometimes adds these)
|
||||
json_str = re.sub(r'//[^\n]*', '', json_str)
|
||||
# Strip trailing commas before } or ]
|
||||
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
|
||||
return json.loads(json_str)
|
||||
|
||||
|
||||
def _check_provider():
|
||||
if not _provider:
|
||||
raise RuntimeError("No LLM API key configured. Set ANTHROPIC_API_KEY or GEMINI_API_KEY in .env")
|
||||
|
||||
|
||||
async def _text_completion(system: str, user_content: str, max_tokens: int = 1024) -> str:
|
||||
_check_provider()
|
||||
if _provider == "anthropic":
|
||||
response = _anthropic_client.messages.create(
|
||||
model=_model,
|
||||
max_tokens=max_tokens,
|
||||
messages=[{"role": "user", "content": f"{system}\n\n{user_content}"}],
|
||||
)
|
||||
return response.content[0].text
|
||||
else:
|
||||
response = _gemini_client.models.generate_content(
|
||||
model=_model,
|
||||
config={"system_instruction": system},
|
||||
contents=user_content,
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
async def _vision_completion(system: str, image_bytes: bytes, user_text: str, max_tokens: int = 512) -> str:
|
||||
_check_provider()
|
||||
if _provider == "anthropic":
|
||||
image_b64 = base64.b64encode(image_bytes).decode()
|
||||
response = _anthropic_client.messages.create(
|
||||
model=_model,
|
||||
max_tokens=max_tokens,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}},
|
||||
{"type": "text", "text": f"{system}\n\n{user_text}"},
|
||||
],
|
||||
}],
|
||||
)
|
||||
return response.content[0].text
|
||||
else:
|
||||
response = _gemini_client.models.generate_content(
|
||||
model=_model,
|
||||
config={"system_instruction": system},
|
||||
contents=[
|
||||
genai_types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"),
|
||||
user_text,
|
||||
],
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
# ── Public API (unchanged signatures) ──
|
||||
|
||||
|
||||
async def parse_brain_dump(raw_text: str, timezone: str) -> dict:
|
||||
from datetime import datetime
|
||||
|
||||
system = f"""You are a task parser and ADHD-friendly planner.
|
||||
Extract structured tasks from this brain dump, then break each task into
|
||||
concrete, actionable steps someone with ADHD can start immediately.
|
||||
|
||||
Today's date: {datetime.now().strftime("%Y-%m-%d")}
|
||||
User's timezone: {timezone}
|
||||
|
||||
Task extraction rules:
|
||||
- Be generous with deadlines — infer from context.
|
||||
- If no deadline is obvious, set priority to 0 (unset).
|
||||
- Unrelated items stay as separate top-level tasks.
|
||||
|
||||
Step rules (applied to every task's subtasks array):
|
||||
- Each step should be 5-15 minutes, specific enough to start without decision paralysis.
|
||||
- First step should be the EASIEST to reduce activation energy.
|
||||
- Steps explicitly mentioned in the brain dump have "suggested": false.
|
||||
- Then ADD 1-3 additional steps the user likely needs but didn't mention, with "suggested": true.
|
||||
Examples: "gather materials", "review before sending", "set a reminder", "test it works".
|
||||
- Keep step titles short and action-oriented.
|
||||
- Every task should have at least 2 steps total.
|
||||
|
||||
Respond ONLY with JSON, no other text.
|
||||
Example:
|
||||
{{
|
||||
"parsed_tasks": [{{
|
||||
"title": "concise task title",
|
||||
"description": "any extra detail from the dump",
|
||||
"deadline": "ISO 8601 or null",
|
||||
"priority": "0-4 integer (0=unset, 1=low, 2=med, 3=high, 4=urgent)",
|
||||
"estimated_minutes": "total for all steps or null",
|
||||
"tags": ["work", "personal", "health", "errands", etc.],
|
||||
"subtasks": [
|
||||
{{"title": "step from the dump", "description": null, "deadline": null, "estimated_minutes": 10, "suggested": false}},
|
||||
{{"title": "AI-suggested next step", "description": null, "deadline": null, "estimated_minutes": 5, "suggested": true}}
|
||||
]
|
||||
}}],
|
||||
"unparseable_fragments": ["text that couldn't be parsed into tasks"]
|
||||
}}"""
|
||||
|
||||
text = await _text_completion(system, f"Brain dump:\n{raw_text}", max_tokens=2048)
|
||||
return _parse_json(text)
|
||||
|
||||
|
||||
|
||||
|
||||
async def generate_step_plan(task_title: str, task_description: str | None, estimated_minutes: int | None) -> list:
|
||||
est = f"{estimated_minutes} minutes" if estimated_minutes else "unknown"
|
||||
system = f"""You are an ADHD-friendly task planner.
|
||||
Break this task into concrete steps of 5-15 minutes each.
|
||||
Each step should be specific enough that someone with ADHD
|
||||
can start immediately without decision paralysis.
|
||||
|
||||
Rules:
|
||||
- First step should be the EASIEST (reduce activation energy)
|
||||
- Steps should be independently completable
|
||||
- Include time estimates per step
|
||||
- Total estimated time should roughly match the task estimate
|
||||
- No step longer than 15 minutes
|
||||
|
||||
Respond ONLY with JSON array:
|
||||
[{{
|
||||
"sort_order": 1,
|
||||
"title": "specific action description",
|
||||
"description": "additional detail if needed",
|
||||
"estimated_minutes": number
|
||||
}}]"""
|
||||
|
||||
text = await _text_completion(system, f"Task: {task_title}\nDescription: {task_description or 'N/A'}\nEstimated total: {est}")
|
||||
return _parse_json(text)
|
||||
|
||||
|
||||
async def analyze_screenshot(
|
||||
screenshot_bytes: bytes,
|
||||
window_title: str,
|
||||
task_context: dict,
|
||||
recent_summaries: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Legacy server-side VLM analysis. Upgraded with friction detection prompt."""
|
||||
steps_text = ""
|
||||
for s in task_context.get("steps", []):
|
||||
cp = f' checkpoint_note="{s["checkpoint_note"]}"' if s.get("checkpoint_note") else ""
|
||||
steps_text += f' - [{s["status"]}] {s["sort_order"]}. {s["title"]} (id={s["id"]}){cp}\n'
|
||||
|
||||
history_text = ""
|
||||
if recent_summaries:
|
||||
for i, summary in enumerate(recent_summaries):
|
||||
history_text += f" - [{(len(recent_summaries) - i) * 5}s ago] {summary}\n"
|
||||
|
||||
system = f"""You are a proactive focus assistant analyzing a user's screen.
|
||||
The user's current task and step progress:
|
||||
Task: {task_context.get("task_title", "")}
|
||||
Goal: {task_context.get("task_goal", "")}
|
||||
Steps:
|
||||
{steps_text} Window title reported by OS: {window_title}
|
||||
{"Recent screen history:" + chr(10) + history_text if history_text else ""}
|
||||
Analyze the current screenshot. Determine:
|
||||
|
||||
1. TASK STATUS: Is the user working on their task? Which step? Any steps completed?
|
||||
2. CHECKPOINT: What specific within-step progress have they made?
|
||||
3. FRICTION DETECTION: Is the user stuck in any of these patterns?
|
||||
- REPETITIVE_LOOP: Switching between same 2-3 windows (copying data manually)
|
||||
- STALLED: Same screen region with minimal changes for extended time
|
||||
- TEDIOUS_MANUAL: Doing automatable work (filling forms, organizing files, transcribing)
|
||||
- CONTEXT_OVERHEAD: Many windows open, visibly searching across them
|
||||
- TASK_RESUMPTION: User just returned to a task they were working on earlier
|
||||
4. INTENT: If viewing informational content, is the user SKIMMING, ENGAGED, or UNCLEAR?
|
||||
5. PROPOSED ACTION: If friction detected, suggest a specific action the AI could take.
|
||||
|
||||
Respond ONLY with JSON:
|
||||
{{
|
||||
"on_task": boolean,
|
||||
"current_step_id": "step UUID or null",
|
||||
"checkpoint_note_update": "within-step progress or null",
|
||||
"steps_completed": ["UUIDs"],
|
||||
"friction": {{
|
||||
"type": "repetitive_loop | stalled | tedious_manual | context_overhead | task_resumption | none",
|
||||
"confidence": 0.0-1.0,
|
||||
"description": "what the user is struggling with or null",
|
||||
"proposed_actions": [
|
||||
{{"label": "action description", "action_type": "auto_extract | brain_dump", "details": "specifics"}}
|
||||
],
|
||||
"source_context": "what info to extract from or null",
|
||||
"target_context": "where to put it or null"
|
||||
}},
|
||||
"intent": "skimming | engaged | unclear | null",
|
||||
"distraction_type": "app_switch | browsing | idle | null",
|
||||
"app_name": "primary visible application",
|
||||
"confidence": 0.0-1.0,
|
||||
"gentle_nudge": "nudge if distracted and no friction action applies, null otherwise",
|
||||
"vlm_summary": "1-sentence factual description of screen"
|
||||
}}"""
|
||||
|
||||
text = await _vision_completion(system, screenshot_bytes, "Analyze this screenshot.")
|
||||
return _parse_json(text)
|
||||
|
||||
|
||||
async def generate_resume_card(
|
||||
task_title: str,
|
||||
goal: str | None,
|
||||
current_step_title: str | None,
|
||||
checkpoint_note: str | None,
|
||||
completed_count: int,
|
||||
total_count: int,
|
||||
next_step_title: str | None,
|
||||
minutes_away: int,
|
||||
attention_score: int | None,
|
||||
) -> dict:
|
||||
system = """Generate a brief, encouraging context-resume card for
|
||||
someone with ADHD returning to their task.
|
||||
Be warm, specific, and action-oriented. No shame. No generic platitudes.
|
||||
Use the checkpoint_note to give hyper-specific context about where they left off.
|
||||
|
||||
Respond ONLY with JSON:
|
||||
{
|
||||
"welcome_back": "short friendly greeting (max 8 words)",
|
||||
"you_were_doing": "1 sentence referencing checkpoint_note specifically",
|
||||
"next_step": "concrete next action with time estimate",
|
||||
"motivation": "1 sentence encouragement (ADHD-friendly, no shame)"
|
||||
}"""
|
||||
|
||||
user_content = f"""Inputs:
|
||||
- Task: {task_title}
|
||||
- Overall goal: {goal or "N/A"}
|
||||
- Current step: {current_step_title or "N/A"}
|
||||
- Current step checkpoint_note: {checkpoint_note or "N/A"}
|
||||
- Steps completed: {completed_count} of {total_count}
|
||||
- Next step after current: {next_step_title or "N/A"}
|
||||
- Time away: {minutes_away} minutes
|
||||
- Attention score before leaving: {attention_score or "N/A"}"""
|
||||
|
||||
text = await _text_completion(system, user_content, max_tokens=256)
|
||||
return _parse_json(text)
|
||||
|
||||
|
||||
async def generate_app_activity_nudge(
|
||||
app_name: str,
|
||||
duration_seconds: int,
|
||||
task_title: str,
|
||||
current_step_title: str | None,
|
||||
checkpoint_note: str | None,
|
||||
) -> str:
|
||||
minutes = duration_seconds // 60
|
||||
duration_text = f"{minutes} minute{'s' if minutes != 1 else ''}" if minutes > 0 else f"{duration_seconds} seconds"
|
||||
|
||||
system = """Generate a single gentle, non-judgmental nudge for someone with ADHD
|
||||
who drifted to a non-work app during a focus session.
|
||||
Reference their specific progress to make returning easier.
|
||||
No shame. Keep it under 30 words.
|
||||
Respond with ONLY the nudge text, no JSON, no quotes."""
|
||||
|
||||
user_content = f"""Context:
|
||||
- Distraction app: {app_name}
|
||||
- Time spent: {duration_text}
|
||||
- Current task: {task_title}
|
||||
- Current step: {current_step_title or "N/A"}
|
||||
- Progress so far: {checkpoint_note or "N/A"}"""
|
||||
|
||||
return (await _text_completion(system, user_content, max_tokens=100)).strip()
|
||||
|
||||
|
||||
async def suggest_work_apps(task_title: str, task_description: str | None) -> dict:
|
||||
system = """Given this task, suggest which Apple apps the user likely needs.
|
||||
Return the most likely single app as the primary suggestion.
|
||||
|
||||
Respond ONLY with JSON:
|
||||
{
|
||||
"suggested_app_scheme": "URL scheme (e.g. mobilenotes://, x-apple-pages://, com.google.docs://)",
|
||||
"suggested_app_name": "human-readable name (e.g. Notes, Pages, Google Docs)"
|
||||
}"""
|
||||
|
||||
text = await _text_completion(system, f"Task: {task_title}\nDescription: {task_description or 'N/A'}", max_tokens=100)
|
||||
return _parse_json(text)
|
||||
|
||||
|
||||
async def prioritize_tasks(tasks_json: list, timezone: str) -> list:
|
||||
from datetime import datetime
|
||||
|
||||
system = """You are an ADHD-friendly task prioritizer.
|
||||
Consider: deadlines, estimated effort, task dependencies,
|
||||
and the user's energy patterns.
|
||||
|
||||
Rules:
|
||||
- Hard deadlines always take top priority
|
||||
- Front-load quick wins (<15min) for momentum
|
||||
- Group errands together
|
||||
- Deprioritize tasks with no deadline and low urgency
|
||||
|
||||
Respond ONLY with JSON array:
|
||||
[{
|
||||
"task_id": "uuid",
|
||||
"recommended_priority": 1-4,
|
||||
"reason": "1-sentence explanation"
|
||||
}]"""
|
||||
|
||||
user_content = f"""Input: {json.dumps(tasks_json)}
|
||||
Current time: {datetime.now().isoformat()}
|
||||
User's timezone: {timezone}"""
|
||||
|
||||
text = await _text_completion(system, user_content, max_tokens=512)
|
||||
return _parse_json(text)
|
||||
283
app/services/push.py
Normal file
283
app/services/push.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""APNs push notification service.
|
||||
|
||||
Uses HTTP/2 APNs provider API with .p8 auth key (token-based auth).
|
||||
Falls back to logging if APNS_KEY_ID / APNS_TEAM_ID / APNS_P8_PATH are not configured.
|
||||
|
||||
Required .env vars:
|
||||
APNS_KEY_ID — 10-char key ID from Apple Developer portal
|
||||
APNS_TEAM_ID — 10-char team ID from Apple Developer portal
|
||||
APNS_P8_PATH — absolute path to the AuthKey_XXXXXXXXXX.p8 file
|
||||
APNS_SANDBOX — True for development/TestFlight, False (default) for production
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
from app.config import settings
|
||||
from app.services.db import get_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache the provider JWT — valid for 60 min, refresh 5 min early
|
||||
_apns_token: str | None = None
|
||||
_apns_token_exp: float = 0.0
|
||||
_private_key = None
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _apns_configured() -> bool:
|
||||
return bool(settings.APNS_KEY_ID and settings.APNS_TEAM_ID and settings.APNS_P8_PATH)
|
||||
|
||||
|
||||
def _make_apns_jwt() -> str:
|
||||
global _apns_token, _apns_token_exp, _private_key
|
||||
|
||||
now = time.time()
|
||||
if _apns_token and now < _apns_token_exp:
|
||||
return _apns_token
|
||||
|
||||
if _private_key is None:
|
||||
with open(settings.APNS_P8_PATH, "rb") as f:
|
||||
_private_key = load_pem_private_key(f.read(), password=None)
|
||||
|
||||
header = _b64(json.dumps({"alg": "ES256", "kid": settings.APNS_KEY_ID}).encode())
|
||||
payload = _b64(json.dumps({"iss": settings.APNS_TEAM_ID, "iat": int(now)}).encode())
|
||||
msg = f"{header}.{payload}".encode()
|
||||
sig = _b64(_private_key.sign(msg, ECDSA(hashes.SHA256())))
|
||||
token = f"{header}.{payload}.{sig}"
|
||||
|
||||
_apns_token = token
|
||||
_apns_token_exp = now + 3300 # 55-minute lifetime (APNs tokens last 60 min)
|
||||
return token
|
||||
|
||||
|
||||
async def _send_apns(device_token: str, aps_payload: dict, push_type: str = "alert") -> bool:
|
||||
host = "api.sandbox.push.apple.com" if settings.APNS_SANDBOX else "api.push.apple.com"
|
||||
url = f"https://{host}/3/device/{device_token}"
|
||||
|
||||
topic = settings.APPLE_BUNDLE_ID
|
||||
if push_type == "liveactivity":
|
||||
topic += ".push-type.liveactivity"
|
||||
|
||||
headers = {
|
||||
"authorization": f"bearer {_make_apns_jwt()}",
|
||||
"apns-topic": topic,
|
||||
"apns-push-type": push_type,
|
||||
"apns-priority": "10",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
resp = await client.post(url, json=aps_payload, headers=headers, timeout=10.0)
|
||||
print(f"APNs response: {resp.status_code} http_version={resp.http_version} token=…{device_token[-8:]} body={resp.text}")
|
||||
print(f"APNs request: url={url} payload={json.dumps(aps_payload)}")
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
if resp.status_code == 410:
|
||||
# Token is dead — device uninstalled or revoked push. Remove from DB.
|
||||
logger.warning(f"APNs 410 Unregistered for token …{device_token[-8:]}, removing from DB")
|
||||
await _remove_device_token(device_token)
|
||||
return False
|
||||
logger.error(f"APNs {resp.status_code} for token …{device_token[-8:]}: {resp.text}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(f"APNs request failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
async def _remove_device_token(device_token: str):
|
||||
"""Remove a dead APNs token from all users."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""UPDATE users SET device_tokens = (
|
||||
SELECT COALESCE(jsonb_agg(t), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(device_tokens) t
|
||||
WHERE t->>'token' != $1
|
||||
) WHERE device_tokens @> $2::jsonb""",
|
||||
device_token,
|
||||
json.dumps([{"token": device_token}]),
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_device_tokens(user_id: str, platform: str | None = None) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT device_tokens FROM users WHERE id = $1::uuid", user_id
|
||||
)
|
||||
if not row or not row["device_tokens"]:
|
||||
return []
|
||||
tokens = (
|
||||
json.loads(row["device_tokens"])
|
||||
if isinstance(row["device_tokens"], str)
|
||||
else row["device_tokens"]
|
||||
)
|
||||
if platform:
|
||||
tokens = [t for t in tokens if t.get("platform", "").startswith(platform)]
|
||||
return tokens
|
||||
|
||||
|
||||
async def register_device_token(user_id: str, platform: str, token: str):
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""UPDATE users SET device_tokens = (
|
||||
SELECT COALESCE(jsonb_agg(t), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(device_tokens) t
|
||||
WHERE t->>'platform' != $2
|
||||
) || $3::jsonb
|
||||
WHERE id = $1::uuid""",
|
||||
user_id,
|
||||
platform,
|
||||
json.dumps([{"platform": platform, "token": token}]),
|
||||
)
|
||||
|
||||
|
||||
async def send_push(user_id: str, platform: str, aps_payload: dict):
|
||||
"""Send an APNs push to all registered tokens for a user/platform."""
|
||||
tokens = await get_device_tokens(user_id, platform)
|
||||
print(f"send_push → user={user_id} platform={platform} tokens={tokens} configured={_apns_configured()}")
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
if not _apns_configured():
|
||||
for t in tokens:
|
||||
logger.info(
|
||||
f"[APNs STUB] platform={t['platform']} token=…{t['token'][-8:]} payload={aps_payload}"
|
||||
)
|
||||
return
|
||||
|
||||
for t in tokens:
|
||||
await _send_apns(t["token"], aps_payload)
|
||||
|
||||
|
||||
async def send_task_added(user_id: str, task_title: str, step_count: int = 0):
|
||||
"""Notify the user that a new task was added."""
|
||||
subtitle = f"{step_count} subtask{'s' if step_count != 1 else ''}"
|
||||
payload = {
|
||||
"aps": {
|
||||
"alert": {"title": task_title, "subtitle": subtitle},
|
||||
"sound": "default",
|
||||
}
|
||||
}
|
||||
for platform in ["iphone", "ipad"]:
|
||||
await send_push(user_id, platform, payload)
|
||||
|
||||
|
||||
async def send_activity_update(user_id: str, task_title: str, task_id=None, started_at: int | None = None):
|
||||
"""Send ActivityKit push to update Live Activity on all devices with current step progress."""
|
||||
tokens = await get_device_tokens(user_id, "liveactivity_update_")
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
step_progress = await _get_step_progress(task_id)
|
||||
now_ts = started_at or int(time.time())
|
||||
content_state = _build_content_state(task_title, now_ts, step_progress)
|
||||
|
||||
if not _apns_configured():
|
||||
for t in tokens:
|
||||
logger.info(f"[ActivityKit STUB] token=…{t['token'][-8:]} state={content_state}")
|
||||
return
|
||||
|
||||
payload = {"aps": {"timestamp": int(time.time()), "content-state": content_state, "event": "update"}}
|
||||
for t in tokens:
|
||||
await _send_apns(t["token"], payload, push_type="liveactivity")
|
||||
|
||||
|
||||
async def send_activity_end(user_id: str, task_title: str = "Session ended", task_id=None):
|
||||
"""Send ActivityKit push-to-end using per-activity update tokens."""
|
||||
tokens = await get_device_tokens(user_id, "liveactivity_update_")
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
now_ts = int(time.time())
|
||||
step_progress = await _get_step_progress(task_id)
|
||||
payload = {
|
||||
"aps": {
|
||||
"timestamp": now_ts,
|
||||
"event": "end",
|
||||
"content-state": _build_content_state(task_title, now_ts, step_progress),
|
||||
"dismissal-date": now_ts,
|
||||
}
|
||||
}
|
||||
|
||||
if not _apns_configured():
|
||||
for t in tokens:
|
||||
logger.info(f"[ActivityKit END STUB] token=...{t['token'][-8:]}")
|
||||
return
|
||||
|
||||
for t in tokens:
|
||||
await _send_apns(t["token"], payload, push_type="liveactivity")
|
||||
|
||||
|
||||
async def _get_step_progress(task_id) -> dict:
|
||||
"""Fetch step progress for a task: completed count, total count, current step title."""
|
||||
if not task_id:
|
||||
return {"stepsCompleted": 0, "stepsTotal": 0, "currentStepTitle": None, "lastCompletedStepTitle": None}
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT title, status FROM steps WHERE task_id = $1 ORDER BY sort_order", task_id
|
||||
)
|
||||
total = len(rows)
|
||||
completed = sum(1 for r in rows if r["status"] == "done")
|
||||
current = next((r["title"] for r in rows if r["status"] in ("in_progress", "pending")), None)
|
||||
last_completed = next((r["title"] for r in reversed(rows) if r["status"] == "done"), None)
|
||||
return {"stepsCompleted": completed, "stepsTotal": total, "currentStepTitle": current, "lastCompletedStepTitle": last_completed}
|
||||
|
||||
|
||||
def _build_content_state(task_title: str, started_at: int, step_progress: dict) -> dict:
|
||||
state = {
|
||||
"taskTitle": task_title,
|
||||
"startedAt": started_at,
|
||||
"stepsCompleted": step_progress["stepsCompleted"],
|
||||
"stepsTotal": step_progress["stepsTotal"],
|
||||
}
|
||||
if step_progress["currentStepTitle"]:
|
||||
state["currentStepTitle"] = step_progress["currentStepTitle"]
|
||||
if step_progress.get("lastCompletedStepTitle"):
|
||||
state["lastCompletedStepTitle"] = step_progress["lastCompletedStepTitle"]
|
||||
return state
|
||||
|
||||
|
||||
async def send_activity_start(user_id: str, task_title: str, task_id=None):
|
||||
"""Send ActivityKit push-to-start to all liveactivity tokens."""
|
||||
tokens = await get_device_tokens(user_id, "liveactivity")
|
||||
if not tokens:
|
||||
return
|
||||
|
||||
now_ts = int(time.time())
|
||||
step_progress = await _get_step_progress(task_id)
|
||||
payload = {
|
||||
"aps": {
|
||||
"timestamp": now_ts,
|
||||
"event": "start",
|
||||
"content-state": _build_content_state(task_title, now_ts, step_progress),
|
||||
"attributes-type": "FocusSessionAttributes",
|
||||
"attributes": {
|
||||
"sessionType": "Focus"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Focus Session Started",
|
||||
"body": task_title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if not _apns_configured():
|
||||
for t in tokens:
|
||||
logger.info(f"[ActivityKit START STUB] token=...{t['token'][-8:]} start payload={payload}")
|
||||
return
|
||||
|
||||
for t in tokens:
|
||||
await _send_apns(t["token"], payload, push_type="liveactivity")
|
||||
69
app/types.py
Normal file
69
app/types.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class TaskStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PLANNING = "planning"
|
||||
READY = "ready"
|
||||
IN_PROGRESS = "in_progress"
|
||||
DONE = "done"
|
||||
DEFERRED = "deferred"
|
||||
|
||||
|
||||
class StepStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
DONE = "done"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class SessionStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
INTERRUPTED = "interrupted"
|
||||
|
||||
|
||||
class DistractionType(StrEnum):
|
||||
APP_SWITCH = "app_switch"
|
||||
OFF_SCREEN = "off_screen"
|
||||
IDLE = "idle"
|
||||
BROWSING = "browsing"
|
||||
|
||||
|
||||
class TaskSource(StrEnum):
|
||||
MANUAL = "manual"
|
||||
BRAIN_DUMP = "brain_dump"
|
||||
VOICE = "voice"
|
||||
|
||||
|
||||
class PlanType(StrEnum):
|
||||
LLM_GENERATED = "llm_generated"
|
||||
USER_DEFINED = "user_defined"
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class FrictionType(StrEnum):
|
||||
REPETITIVE_LOOP = "repetitive_loop"
|
||||
STALLED = "stalled"
|
||||
TEDIOUS_MANUAL = "tedious_manual"
|
||||
CONTEXT_OVERHEAD = "context_overhead"
|
||||
TASK_RESUMPTION = "task_resumption"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class IntentType(StrEnum):
|
||||
SKIMMING = "skimming"
|
||||
ENGAGED = "engaged"
|
||||
UNCLEAR = "unclear"
|
||||
|
||||
|
||||
class ProactiveUserChoice(StrEnum):
|
||||
ACCEPTED = "accepted"
|
||||
DECLINED = "declined"
|
||||
ALTERNATIVE_CHOSEN = "alternative_chosen"
|
||||
|
||||
|
||||
class DevicePlatform(StrEnum):
|
||||
MAC = "mac"
|
||||
IPAD = "ipad"
|
||||
IPHONE = "iphone"
|
||||
84
environment.yml
Normal file
84
environment.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
name: lockinbro
|
||||
channels:
|
||||
- defaults
|
||||
dependencies:
|
||||
- _libgcc_mutex=0.1
|
||||
- _openmp_mutex=5.1
|
||||
- bzip2=1.0.8
|
||||
- ca-certificates=2025.12.2
|
||||
- ld_impl_linux-64=2.44
|
||||
- libexpat=2.7.5
|
||||
- libffi=3.4.4
|
||||
- libgcc=15.2.0
|
||||
- libgcc-ng=15.2.0
|
||||
- libgomp=15.2.0
|
||||
- libstdcxx=15.2.0
|
||||
- libstdcxx-ng=15.2.0
|
||||
- libuuid=1.41.5
|
||||
- libxcb=1.17.0
|
||||
- libzlib=1.3.1
|
||||
- ncurses=6.5
|
||||
- openssl=3.5.5
|
||||
- packaging=25.0
|
||||
- pip=26.0.1
|
||||
- pthread-stubs=0.3
|
||||
- python=3.12.13
|
||||
- readline=8.3
|
||||
- setuptools=80.10.2
|
||||
- sqlite=3.51.2
|
||||
- tk=8.6.15
|
||||
- tzdata=2026a
|
||||
- wheel=0.46.3
|
||||
- xorg-libx11=1.8.12
|
||||
- xorg-libxau=1.0.12
|
||||
- xorg-libxdmcp=1.1.5
|
||||
- xorg-xorgproto=2024.1
|
||||
- xz=5.8.2
|
||||
- zlib=1.3.1
|
||||
- pip:
|
||||
- alembic==1.18.4
|
||||
- annotated-doc==0.0.4
|
||||
- annotated-types==0.7.0
|
||||
- anthropic==0.86.0
|
||||
- anyio==4.13.0
|
||||
- argon2-cffi==25.1.0
|
||||
- argon2-cffi-bindings==25.1.0
|
||||
- asyncpg==0.31.0
|
||||
- certifi==2026.2.25
|
||||
- cffi==2.0.0
|
||||
- click==8.3.1
|
||||
- cryptography==46.0.6
|
||||
- distro==1.9.0
|
||||
- docstring-parser==0.17.0
|
||||
- ecdsa==0.19.2
|
||||
- fastapi==0.135.2
|
||||
- greenlet==3.3.2
|
||||
- h11==0.16.0
|
||||
- httpcore==1.0.9
|
||||
- httptools==0.7.1
|
||||
- httpx==0.28.1
|
||||
- idna==3.11
|
||||
- jiter==0.13.0
|
||||
- mako==1.3.10
|
||||
- markupsafe==3.0.3
|
||||
- pyasn1==0.6.3
|
||||
- pycparser==3.0
|
||||
- pydantic==2.12.5
|
||||
- pydantic-core==2.41.5
|
||||
- pydantic-settings==2.13.1
|
||||
- python-dotenv==1.2.2
|
||||
- python-jose==3.5.0
|
||||
- python-multipart==0.0.22
|
||||
- pyyaml==6.0.3
|
||||
- rsa==4.9.1
|
||||
- six==1.17.0
|
||||
- sniffio==1.3.1
|
||||
- sqlalchemy==2.0.48
|
||||
- starlette==1.0.0
|
||||
- typing-extensions==4.15.0
|
||||
- typing-inspection==0.4.2
|
||||
- uvicorn==0.42.0
|
||||
- uvloop==0.22.1
|
||||
- watchfiles==1.1.1
|
||||
- websockets==16.0
|
||||
prefix: /home/devuser/miniconda3/envs/lockinbro
|
||||
59
requirements.txt
Normal file
59
requirements.txt
Normal file
@@ -0,0 +1,59 @@
|
||||
alembic==1.18.4
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anthropic==0.86.0
|
||||
anyio==4.13.0
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
asyncpg==0.31.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.6
|
||||
click==8.3.1
|
||||
cryptography==46.0.6
|
||||
distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
docstring_parser==0.17.0
|
||||
ecdsa==0.19.2
|
||||
email-validator==2.3.0
|
||||
fastapi==0.135.2
|
||||
google-auth==2.49.1
|
||||
google-genai==1.69.0
|
||||
greenlet==3.3.2
|
||||
h11==0.16.0
|
||||
h2>=4.1.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
jiter==0.13.0
|
||||
Mako==1.3.10
|
||||
MarkupSafe==3.0.3
|
||||
packaging @ file:///home/task_176104874243446/conda-bld/packaging_1761049080023/work
|
||||
psycopg2-binary==2.9.11
|
||||
pyasn1==0.6.3
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==3.0
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.1
|
||||
pydantic_core==2.41.5
|
||||
python-dotenv==1.2.2
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.22
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.0
|
||||
rsa==4.9.1
|
||||
setuptools==80.10.2
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.48
|
||||
starlette==1.0.0
|
||||
tenacity==9.1.4
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.6.3
|
||||
uvicorn==0.42.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==16.0
|
||||
wheel==0.46.3
|
||||
Reference in New Issue
Block a user