diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..b48ea18 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,26 @@ +# CodeRabbit configuration +# Docs: https://docs.coderabbit.ai/getting-started/configure-coderabbit + +reviews: + # Default is "chill" — we want it to surface issues without + # blocking on style nits. + profile: chill + + # Auto-review configuration. By default CodeRabbit only reviews PRs + # where the BASE branch is the repo's default branch (main). + # We open one-off full-repo audit PRs against the `coderabbit-root` + # branch (a non-default branch pointing at the initial commit) so + # we can ask `@coderabbitai full review` over the entire codebase. + # Add coderabbit-root to the allowed base list so those audit PRs + # actually get reviewed instead of "Review skipped". + auto_review: + enabled: true + drafts: false + base_branches: + - "main" + - "coderabbit-root" + + # Allow @coderabbitai review / full review on any PR opened against + # any base branch in this repo — overrides the default "only on + # default-branch base" policy so manual triggers always work. + request_changes_workflow: false diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6ab35a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.env +.git +.github +.pytest_cache +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +*.log +DEVLOG.md +docs/pdf_rendered +tests \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d041690 --- /dev/null +++ b/.env.example @@ -0,0 +1,135 @@ +## Optional for AI-assisted workflow and assistant features. +OPENAI_API_KEY= +OPENAI_MODEL_DEFAULT=gpt-5.4-mini +OPENAI_MODEL_HIGH_TRUST=gpt-5.4 +OPENAI_MODEL_MID_TIER=gpt-5.4-mini +OPENAI_MODEL_PRODUCT_HELP=gpt-5.4-mini +OPENAI_MODEL_APPLICATION_QA=gpt-5.4 +OPENAI_REASONING_DEFAULT=medium +OPENAI_REASONING_HIGH_TRUST=high +OPENAI_REASONING_PROFILE=medium +OPENAI_REASONING_JOB=medium +OPENAI_REASONING_FIT=medium +OPENAI_REASONING_TAILORING=medium +OPENAI_REASONING_STRATEGY=medium +OPENAI_REASONING_REVIEW=high +OPENAI_REASONING_RESUME_GENERATION=high +OPENAI_REASONING_PRODUCT_HELP=medium +OPENAI_REASONING_APPLICATION_QA=high + +## Optional app base URL. Used as the default auth redirect URL when no +## explicit SUPABASE_AUTH_REDIRECT_URL is provided. +APP_BASE_URL=http://localhost:3000 + +## Required for the current login-first resume workflow, Google sign-in, +## persisted saved-workspace reload, and account-level quotas. +SUPABASE_URL= +SUPABASE_ANON_KEY= +## Service-role key used ONLY by the cached_jobs writer +## (/admin/refresh-cache) and reader (/jobs/search). Bypasses RLS — +## never send to the frontend, never use for user-scoped tables. +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_AUTH_REDIRECT_URL=http://localhost:3000/workspace +SUPABASE_APP_USERS_TABLE=app_users +SUPABASE_USAGE_EVENTS_TABLE=usage_events +SUPABASE_SAVED_WORKSPACES_TABLE=saved_workspaces +SUPABASE_SAVED_JOBS_TABLE=saved_jobs +SUPABASE_RESUME_BUILDER_SESSIONS_TABLE=resume_builder_sessions +SUPABASE_CACHED_JOBS_TABLE=cached_jobs +SAVED_WORKSPACE_TTL_HOURS=24 +## Shared secret guarding /admin/refresh-cache. Set the same value in +## the Supabase pg_cron job's HTTP headers so the cron can include +## it. Rotate if leaked. +REFRESH_CACHE_SECRET= + +## Optional job-search backend settings. Keep ENABLE_JOB_SEARCH_BACKEND=false +## until the separate backend service is available in the target environment. +ENABLE_JOB_SEARCH_BACKEND=false +JOB_BACKEND_BASE_URL=http://localhost:8000 +JOB_BACKEND_HOSTPORT= +# Comma-separated lists of board / site identifiers. Each is the +# {token} in https://boards-api.greenhouse.io/v1/boards/{token}/jobs +# or the {site} in https://api.lever.co/v0/postings/{site}. The +# /admin/refresh-cache endpoint pulls every active job from each. +# Bad tokens surface as "error" in the refresh report's +# providers.greenhouse.errors[] (or .lever) — trim them from your +# env after the first refresh shows which ones failed. +# +# Validated seed list ~58 Greenhouse + 6 Lever = ~8.7K active jobs. +# Companies that previously used Greenhouse/Lever and migrated to +# Ashby (Linear, Vercel, Netlify, Supabase, OpenAI, ...) were +# trimmed after the first refresh — they 404'd. Add your own and +# they'll either show up as "ok" or "error" in the next refresh +# report. +GREENHOUSE_BOARD_TOKENS=databricks,stripe,mongodb,anthropic,cloudflare,datadog,waymo,okta,samsara,zscaler,roblox,airbnb,brex,sofi,gitlab,scaleai,intercom,affirm,gleanwork,figma,fivetran,pinterest,elastic,reddit,twilio,robinhood,boxinc,lyft,smartsheet,asana,instacart,cresta,motional,dropbox,nuro,zoominfo,discord,moloco,gusto,wayve,faire,classpass,chime,fastly,amplitude,monzo,twitch,peloton,opendoor,mercury,mozilla,qualtrics,sumologic,pagerduty,launchdarkly,mixpanel,marqeta,wikimedia,algolia,triplelift,airtable,descript,calendly,typeface,narvar,glossier,placerlabs,modernhealth,lastpass,honeycomb,udacity,bitwarden,assemblyai,coursera,planetscale,inflectionai,kayak,allbirds,hubspot +LEVER_SITE_NAMES=dnb,plaid,mistral,palantir,netflix,attentive +## Ashby boards. Modern AI/dev-tools tier (Linear, Cursor, Replit, +## Vercel, Cohere, Mistral, Sierra, Decagon, ...) — companies that +## migrated off Greenhouse/Lever in 2023-2025. Endpoint: +## https://api.ashbyhq.com/posting-api/job-board/{token} +ASHBY_BOARD_TOKENS=mistral,notion,elevenlabs,cohere,sierra,ramp,decagon,plaid,cursor,replit,lovable,perplexity,deepgram,gamma,writer,supabase,n8n,workos,exa,linear,render,substack,character,posthog,poolside,column,railway,warp,statsig,pinecone,weaviate,stytch,pika,runway,nylas,clerk +## Workday tokens are tenant:host:site triples — each Fortune 500 +## company runs its own Workday tenant on a numbered host +## (wd1.myworkdayjobs.com, wd5, etc) at a public site. Validated +## tenants below unlock ~14.5K jobs. The bulk-validation script in +## scripts/probe_workday.py (or a similar one-off) finds more — +## many big companies (AMD, Intel, Tesla, Cisco, IBM) require +## tenant-specific request shapes that the default body doesn't +## satisfy; those return 422 and are excluded. +WORKDAY_BOARD_TOKENS=micron:wd1:External,nvidia:wd5:NVIDIAExternalCareerSite,citi:wd5:2,walmart:wd5:WalmartExternal,hpe:wd5:Jobsathpe,adobe:wd5:external_experienced,boeing:wd1:EXTERNAL_CAREERS,disney:wd5:disneycareer,hp:wd5:ExternalCareerSite,blackrock:wd1:BlackRock_Professional,workday:wd5:Workday + +## Frontend origin settings for the Next.js transition. +FRONTEND_APP_URL=http://localhost:3000 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +## Leave AUTH_COOKIE_DOMAIN empty on localhost. In production, set it to +## your parent domain (for example, .job-application-copilot.xyz) so the +## landing and workspace subdomains share the same HttpOnly session. +AUTH_COOKIE_DOMAIN= +AUTH_COOKIE_SECURE=false +AUTH_COOKIE_SAMESITE=lax + +## AI-assisted workflow and the in-app assistant are login-required in the +## active UI. This flag controls whether the workflow button also enforces that. +AUTH_REQUIRED_FOR_ASSISTED_WORKFLOW=true +AUTH_DEFAULT_PLAN_TIER=free +AUTH_DEFAULT_ACCOUNT_STATUS=active +## Only internal unrestricted testing accounts belong here. +## Leave normal quota-test accounts out of this list so they stay on the free-tier path. +AUTH_INTERNAL_USER_EMAILS= +FREE_TIER_MAX_CALLS_PER_DAY=12 +FREE_TIER_MAX_TOKENS_PER_DAY=60000 +PAID_TIER_MAX_CALLS_PER_DAY=80 +PAID_TIER_MAX_TOKENS_PER_DAY=400000 + +## Lemon Squeezy subscription integration. +## +## The backend uses these to validate webhook deliveries (HMAC over the +## raw body) and to mint customer portal URLs. The frontend uses the +## NEXT_PUBLIC_ values below to build the hosted checkout URL. +## +## Until these are populated the backend webhook returns 503 (with a +## Retry-After) on every delivery and the pricing CTA falls back to +## "Coming soon" for Pro / "Contact us" (mailto) for Business. This +## lets the integration ship to main without LS being live. +## +## Build against LS *sandbox* mode by default. Sandbox has its own +## webhook secret + API key; the dashboard URL is the same surface. +## See docs/lemon-squeezy.md for the full setup walkthrough. +AIJOBAGENT_LEMONSQUEEZY_API_KEY= +AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET= +AIJOBAGENT_LEMONSQUEEZY_STORE_ID= +## LS variant_id values (numeric strings on the variant API resource). +## One per paid tier; mapping flows +## variant_id -> tier -> TIER_CAPS +## inside backend.webhooks.lemonsqueezy._tier_for_variant. +AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_PRO= +AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_BUSINESS= + +## Public (frontend) LS env. NEXT_PUBLIC_ prefix gets these inlined +## into the JS bundle at build time. The store_id here is the +## subdomain piece (e.g. "yourstore" -> https://yourstore.lemonsqueezy.com). +## variant ids must match the AIJOBAGENT_* values above (same upstream +## resource, two views). +NEXT_PUBLIC_LEMONSQUEEZY_STORE_ID= +NEXT_PUBLIC_LEMONSQUEEZY_PRODUCT_VARIANT_PRO= +NEXT_PUBLIC_LEMONSQUEEZY_PRODUCT_VARIANT_BUSINESS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d5de46f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Sync dependencies + run: uv sync --group dev --frozen + + - name: Compile check + run: uv run python -m compileall backend src tests + + - name: Run tests + run: uv run pytest -v + + frontend: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Build + working-directory: frontend + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c35f0f9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,117 @@ +name: Build & Deploy + +on: + push: + branches: [main] + +env: + IMAGE: ghcr.io/leanderantony/ai_job_application_agent/api + VPS_APP_DIR: /home/ubuntu/AI_Job_Application_Agent + +jobs: + build-and-push: + name: Build Docker image to GHCR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=registry,ref=${{ env.IMAGE }}:cache + cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max + + deploy: + name: Deploy to VPS + runs-on: ubuntu-latest + needs: build-and-push + permissions: + contents: read + packages: read + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ github.token }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Prepare VPS directory + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_PORT }} + script: | + mkdir -p "${{ env.VPS_APP_DIR }}/backend/vps" + + - name: Copy deployment files + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_PORT }} + source: backend/vps/docker-compose.yml,backend/vps/docker-compose.override.yml + target: ${{ env.VPS_APP_DIR }}/backend/vps + strip_components: 2 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + env: + IMAGE: ${{ env.IMAGE }} + GITHUB_ACTOR: ${{ env.GITHUB_ACTOR }} + GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_PORT }} + envs: IMAGE,GITHUB_TOKEN,GITHUB_ACTOR + script: | + echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + docker pull "$IMAGE:latest" + cd "${{ env.VPS_APP_DIR }}/backend/vps" + docker compose -p ai_job_application_agent up -d --no-deps --no-build --force-recreate api + docker logout ghcr.io + + - name: Health check + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_PORT }} + script: | + echo "Waiting for AI Job Application Agent API to be healthy..." + for i in $(seq 1 12); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' ai-job-application-agent-api 2>/dev/null) + echo "Attempt $i: $STATUS" + if [ "$STATUS" = "healthy" ]; then + echo "Container is healthy" + exit 0 + fi + sleep 5 + done + echo "Container did not become healthy in time" + docker logs ai-job-application-agent-api --tail 30 + exit 1 diff --git a/.gitignore b/.gitignore index b7faf40..854cda6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ downloads/ eggs/ .eggs/ lib/ +!frontend/src/lib/ +!frontend/src/lib/** lib64/ parts/ sdist/ @@ -136,6 +138,11 @@ celerybeat.pid # Environments .env +.env.* +**/.env +**/.env.* +!.env.example +!**/.env.example .envrc .venv env/ @@ -168,6 +175,14 @@ dmypy.json # Cython debug symbols cython_debug/ +# Local planning notes +improvements.md +deployment-plan.md +docs/project_strategy.md +docs/recommended-changes-status-2026-03-16.md +docs/supabase-setup-checklist.md +docs/pdf_template/*.pdf + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -205,3 +220,66 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Streamlit cache and config +.streamlit/ + +# Keep tracked templates, ignore real local secrets +!.streamlit/ +!.streamlit/*.example +!.streamlit/*.example.toml + +# Local secret files and credentials +openai_key.txt +github_token.txt +github_oauth_client_id.txt +github_oauth_client_secret.txt +github_oauth_redirect_uri.txt +*.pem +*.key +*.crt +secrets.toml +.streamlit/secrets.toml +frontend/.env.local +frontend/.next/ +frontend/node_modules/ +deploy/vps/.env +.vercel/ + +# Local databases and caches +*.sqlite +*.sqlite3 +*.db +chroma/ +chroma_db/ +analysis_cache/ +analysis_cache.sqlite3 + +# User-provided uploads and generated artifacts +uploads/ +user_data/ +tmp/ +tmp_*/ +artifacts/ +exports/ +reports/ + +# Keep tracked demo assets +!static/demo_job_description/ +!static/demo_job_description/** +!static/demo_resume/ +!static/demo_resume/** + +# Local-only working files (codex CLI process logs, eval artifacts) +.codex-local/ + +# TypeScript incremental build cache +*.tsbuildinfo + +# Local design/planning notes +VISUAL_LOOP_MVP.md +# Claude Code session state + worktrees (harness-managed, not source) +.claude/ + +# Design system reference material — local-only handoff/specs/screens +design_system/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/DEVLOG.md b/DEVLOG.md new file mode 100644 index 0000000..bb95c33 --- /dev/null +++ b/DEVLOG.md @@ -0,0 +1,723 @@ +# DEVLOG - AI Job Application Agent + +This document tracks notable implementation milestones and technical decisions. + +Historical note: + +- earlier entries reflect the product and architecture assumptions at the time they were written +- later entries supersede earlier history when the product direction changed, especially around LinkedIn import, workflow history, session-level quota UX, and persistence structure + +## Day 1: Project Setup and Resume Parsing + +- Initialized the repository, virtual environment, license, and Streamlit shell. +- Added an MVP navigation flow covering resume upload, LinkedIn import, job search, and manual JD input. +- Chose lightweight parsing dependencies first: + - `pypdf` + - `python-docx` + +## Day 2: Demo Inputs and Unified File Parsing + +- Added sample resumes and sample job descriptions under `static/`. +- Updated parsing code so both uploaded files and local demo files work through the same logic. +- Added basic job-description cleaning and simple extraction for title, location, experience, and skills. + +## Day 3: LinkedIn Import and Session Persistence + +- Added LinkedIn data-export ZIP ingestion instead of direct LinkedIn API access. +- Parsed summary, education, skills, preferences, publications, and position history where present. +- Stored parsed payloads in `st.session_state` so the UI survives navigation and reruns. + +## Day 4: Repo Structure Alignment With GitHub Agent + +- Moved the active application logic into `src/`. +- Refactored `app.py` into a cleaner Streamlit entrypoint with section-level render functions. +- Added parser-focused tests under `tests/`. +- Added `docs/architecture.md`, ADR files under `docs/adr/`, a roadmap, and a real README. +- Improved parsing behavior: + - TXT resumes are now supported + - job-description cleanup preserves line breaks + - JD source persistence now stores parsed text instead of the raw uploaded file object + +## Day 5: Modular UI and Defensive Parser Refactor + +- Reduced root `app.py` to a thin entrypoint and moved UI composition into `src/ui/`. +- Split the codebase into clearer layers: + - `src/parsers/` for raw ingestion and extraction + - `src/services/` for normalization and deterministic workflow helpers + - `src/ui/` for Streamlit theme, components, navigation, and pages +- Kept top-level parser modules as compatibility wrappers so existing imports and tests continue to work. +- Added `ResumeDocument` to the shared schemas and started using typed objects more consistently in the UI. +- Hardened parser behavior with more defensive checks: + - explicit empty-file handling + - clearer unsupported-format failures + - safer PDF and DOCX open failures + - better LinkedIn ZIP validation and normalization +- Verified the refactor with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 6: Deterministic Fit Analysis Foundation + +- Expanded the typed schema layer with: + - `FitAnalysis` + - `TailoredResumeDraft` + - richer `CandidateProfile` source signals +- Added shared keyword taxonomy in `src/taxonomy.py` so resume and JD matching use the same vocabulary. +- Improved profile normalization in `src/services/profile_service.py`: + - basic name and location inference from resumes + - keyword extraction from resume text + - merge logic for resume and LinkedIn candidate sources + - candidate-context text assembly for downstream analysis +- Improved job normalization in `src/services/job_service.py`: + - empty-input validation + - must-have and nice-to-have signal extraction + - cleaner requirement deduplication +- Added new deterministic workflow services: + - `src/services/fit_service.py` + - `src/services/tailoring_service.py` +- Updated the Streamlit JD page to render: + - merged candidate readiness + - fit score and gap analysis + - first-pass tailored resume guidance +- Extended test coverage for: + - profile normalization + - job normalization + - fit scoring + - tailoring output +- Verified the new workflow layer with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 7: Supervised Agent Workflow Layer + +- Added the first supervised multi-agent stack under `src/agents/`: + - `profile_agent.py` + - `job_agent.py` + - `fit_agent.py` + - `tailoring_agent.py` + - `review_agent.py` + - `orchestrator.py` +- Added `src/prompts.py` for centralized grounded prompt construction. +- Added `src/openai_service.py` as a thin OpenAI wrapper with JSON-response validation and typed failure handling. +- Expanded schemas with typed agent outputs and `AgentWorkflowResult`. +- Kept the system defensive: + - the agent workflow only runs when the user explicitly clicks a button + - OpenAI usage is optional + - if model execution is unavailable or fails, orchestration falls back to deterministic output +- Updated the JD page so it can now: + - run supervised orchestration on demand + - cache workflow results against the current candidate/JD signature + - render profile positioning, fit narrative, tailoring output, and review notes +- Added orchestrator tests covering: + - deterministic fallback mode + - successful AI-assisted mode with a fake service + - graceful fallback when AI execution fails +- Verified the agent layer with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 8: Report Builder and Export Layer + +- Added `src/report_builder.py` to assemble a deterministic application package from: + - candidate profile + - job description + - fit analysis + - tailored draft + - optional supervised agent output +- Added `src/exporters.py` for initial package export handling. +- Updated the JD page to: + - render an application-package preview + - expose Markdown download + - automatically upgrade the package when agent output is available +- Updated top-level UI copy so the app now reflects package/export readiness. +- Added tests covering: + - report construction + - export byte formatting +- Verified the report/export layer with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 9: Playwright-First PDF Export + +- Upgraded the export layer to support polished PDF output. +- Chose the same pattern used in the GitHub agent: + - Playwright/Chromium as the primary PDF renderer + - ReportLab as the fallback backend +- Updated the JD page so users can: + - prepare a PDF package explicitly + - download a polished PDF once it is generated +- Kept Markdown export as the editable output format for users who want to make manual changes before sharing. +- Added export tests covering: + - HTML report generation + - ReportLab fallback when the Playwright backend fails + - typed failure handling when both PDF backends fail +- Installed and validated local PDF dependencies, including Chromium for Playwright. +- Verified PDF export behavior with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + - direct Playwright and fallback PDF smoke checks + +## Day 10: Codebase Hardening and CI + +- Fixed the pytest configuration so tests can resolve `src` imports: + - added `[tool.pytest.ini_options]` with `pythonpath = ["."]` to `pyproject.toml` +- Expanded the skill taxonomy in `src/taxonomy.py`: + - hard skills went from 20 to ~140 entries covering programming languages, data/ML, frameworks, databases, cloud, web/API, and DevOps + - soft skills went from 10 to 30 entries +- Extracted shared utility functions into `src/utils.py`: + - `dedupe_strings` and `match_keywords` were duplicated across four service files and the JD parser + - replaced all copies with imports from the shared module +- Removed Streamlit coupling from the parser layer: + - removed `@st.cache_data` decorators and `import streamlit as st` from `src/parsers/resume.py` and `src/parsers/jd.py` + - parsers are now pure functions that work in any context (FastAPI, CLI, tests) +- Updated the GitHub Actions CI workflow: + - scoped triggers to the `main` branch + - switched to the official `astral-sh/setup-uv` action + - added a `python -m compileall` check step + - enabled verbose test output +- Removed obsolete `requirements.txt` and `requirements-dev.txt` export files since `pyproject.toml` and `uv.lock` are the dependency source of truth. +- Normalized all DEVLOG verification commands from `venv\Scripts\python.exe` to `uv run`. +- Verified the hardened codebase with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 11: Scope Tightening Around Resume + JD Workflow + +- Removed LinkedIn import from the active product and codebase. +- Simplified candidate-profile handling so the working profile comes directly from resume parsing. +- Deleted LinkedIn parser modules and their test coverage. +- Updated UI navigation and copy to reflect the narrower, lower-friction intake flow. +- Added an ADR documenting why LinkedIn export ingestion was removed from the product scope. +- Verified the removal pass with targeted search, compile checks, and focused tests. + +## Day 12: Review-Driven Iteration, Strategy, and Observability + +- Added a bounded review-revision loop in the orchestrator so rejected tailoring output is revised before finalizing the workflow result. +- Added the `StrategyAgent` and integrated it into the supervised workflow. +- Added structured JSON logging for workflow and OpenAI request lifecycle events. +- Added session-level OpenAI usage tracking and budget guards. +- Refactored Streamlit state access behind `src/ui/state.py` and moved UI workflow orchestration into `src/ui/workflow.py`. +- Verified the architecture pass with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 13: Tailored Resume Artifact and Export Expansion + +- Added a dedicated `ResumeGenerationAgent` after review in the supervised pipeline. +- Added `src/resume_builder.py` to build a direct-use tailored resume artifact from grounded workflow state. +- Added resume themes: + - `classic_ats` + - `modern_professional` +- Extended export support so both report and tailored resume can be exported as Markdown and PDF. +- Added a combined ZIP export bundle for the resume and report together. +- Added resume diff support in `src/resume_diff.py` and exposed original-vs-tailored comparison in the UI. +- Verified the new artifact and export flow with: + - `uv run pytest` + - `uv run python -m compileall app.py src tests` + +## Day 14: Grounded Assistant, Model Routing, and Responses API Migration + +- Added a shared two-mode assistant panel with: + - `Using the App` + - `About My Resume` +- Implemented the assistant as one service with explicit grounded modes instead of creating more orchestrator agents. +- Added per-task model routing so high-trust tasks can use stronger models while lower-risk tasks stay on cheaper tiers. +- Migrated the OpenAI wrapper from Chat Completions to the Responses API. +- Extended usage tracking to retain per-model totals internally while keeping only session-capacity messaging in the UI. +- Added the model sizing and routing reference in `docs/model-latency-and-cost-estimates.md`. +- Added Google sign-in architecture planning in `docs/google-signin-implementation-plan.md`. +- Added ADRs for: + - the two-mode assistant decision + - Google sign-in via Supabase as the persistent identity direction +- Verified the current integrated state with: + - `uv run pytest` + - successful commit and push to `origin/main` + +## Day 15: Google Sign-In Foundation + +- Added `src/auth_service.py` as a Supabase-backed auth wrapper for: + - Google OAuth start + - auth-code exchange + - session restore + - sign-out +- Added Supabase auth configuration in `src/config.py` and example environment variables for local setup. +- Extended `src/ui/state.py` with authenticated user, token, and auth-error state helpers. +- Bootstrapped auth callback handling and session restoration in the Streamlit app shell. +- Added a sidebar account panel for sign-in and sign-out. +- Gated the AI-assisted workflow behind authenticated account state while keeping deterministic resume and JD flows available without login. +- Added focused auth tests and verified the integration with: + - `uv run pytest` + +Persistent per-user usage storage, saved artifact history, and quotas are intentionally left for later Supabase-backed phases. + +## Day 16: Persistent App User Record + +- Added `src/user_store.py` to sync a lightweight `app_users` record after Google sign-in and on session restore. +- Added `AppUserRecord` to the shared schema layer for plan-tier and account-status state. +- Extended config and environment examples for the `app_users` table name and default account metadata. +- Updated the sidebar account panel to surface persisted plan and account status when the sync succeeds. +- Kept login resilient: auth still works even if the Supabase table or RLS policy is not ready yet. + +## Day 17: External Usage Persistence on Supabase Postgres + +- Added `src/usage_store.py` to persist assisted usage events in Supabase Postgres for authenticated users. +- Extended `src/openai_service.py` with an optional usage-event callback so persistence stays transport-agnostic. +- Wired authenticated usage-event recording from `src/ui/workflow.py` without leaking Streamlit concerns into the service layer. + +## Day 18: Deterministic JD Parsing Re-baseline + +- Reverted the experimental resume/JD parser-verifier agent layer and returned intake parsing to the deterministic path. +- Kept the deterministic JD parsing improvements that were useful on their own: + - broader extraction for `Required Experience:` style phrasing + - filtering `Location:` lines out of preferred / nice-to-have buckets + - real fixture coverage for JD PDF and DOCX samples already stored under `static/demo_job_description/` +- Verified the rollback plus retained parser improvements with: + - `uv run pytest tests/test_profile_service.py tests/test_job_service.py tests/test_jd_parser.py tests/test_resume_parser.py tests/test_orchestrator.py` + +## Day 18 (parallel track): Single-Pass Review-Correction Workflow + +- Removed the live `ProfileAgent` and `JobAgent` stages from the supervised workflow because they were mostly restating deterministic inputs without adding enough value for the latency cost. +- Simplified the active orchestrator path to: + - fit + - tailoring + - strategy + - review + - resume generation +- Removed the bounded rerun loop that previously sent tailoring and strategy back through another full pass after review. +- Changed Review so it can return direct corrections for tailoring and strategy, and the orchestrator now feeds those corrected outputs straight into final resume generation. +- Removed interview-theme style outputs that were adding contract size without being core to the current product output. +- Updated the UI, payload layer, and report rendering so they reflect the smaller workflow and the direct-correction review model. +- Verified the redesign with focused workflow, prompt, builder, and UI test coverage. + +## Day 19: Model Routing And Output Budget Tuning + +- Rebalanced reasoning effort by task based on real runtime logs instead of keeping one default posture for every agent. +- Changed the active routing defaults to: + - `fit`: `gpt-5-mini-2025-08-07` with `low` reasoning + - `tailoring`: `gpt-5-mini-2025-08-07` with `medium` reasoning + - `strategy`: `gpt-5-mini-2025-08-07` with `low` reasoning + - `review`: `gpt-5.4` with `medium` reasoning + - `resume_generation`: `gpt-5.4` with `medium` reasoning +- Increased the Review output budget to start at 4000 tokens so the stage does not immediately fall into retry-on-truncation for corrected JSON payloads. +- Reduced oversized output caps where observed usage made the previous limits unnecessary: + - `fit`: 1600 + - `strategy`: 1500 + - `resume_generation`: 3000 +- Kept `tailoring` at 3200 and `review` at 4000 because they still carry the heaviest grounded payloads in the current flow. +- Verified the new routing and cap defaults with targeted orchestration and OpenAI-service tests. + +## Day 20: Review Approval Semantics And Backward Compatibility + +- Clarified Review semantics so `approved` now means the final corrected output is safe to use, not that the incoming tailoring or strategy draft was perfect before correction. +- Added `unresolved_issues` to the review contract so the app can distinguish between: + - issues found in the incoming draft + - blockers that still remain after correction +- Updated UI and report labels to show `Approved After Corrections` when Review repaired the output successfully. +- Added backward-compatible access patterns so older saved or in-memory `ReviewAgentOutput` objects without `unresolved_issues` do not crash the app. +- Logged PDF-output quality as a follow-up documentation item because export aesthetics still need a dedicated pass even though workflow runtime is now much healthier. +- Added the `usage_events` SQL schema and RLS policies in `docs/supabase-usage-events.sql`. +- Kept assisted requests resilient: usage persistence failures are logged but do not break the user-facing AI response. + +## Day 18 (parallel track 2): Daily Quotas From Persisted Usage + +- Added `src/quota_service.py` to compute per-user daily assisted limits from persisted `usage_events`. +- Extended `src/usage_store.py` with daily usage aggregation for the current UTC day. +- Wired quota checks into `src/openai_service.py` as a preflight hook so assisted requests stop cleanly when the daily cap is exhausted. +- Updated the JD workflow UI to show daily remaining assisted capacity alongside the existing session-level view. +- Added plan-tier daily quota configuration through environment variables for free and paid tiers. + +## Day 19 (parallel track 2): Workflow History and Artifact Metadata + +- Added `src/history_store.py` to persist authenticated workflow runs and artifact metadata in Supabase Postgres. +- Wired supervised workflow completion to create `workflow_runs` records. +- Wired export preparation to create `artifacts` records for generated PDFs and ZIP bundles. +- Added recent workflow and artifact history to the sidebar account panel. +- Added Supabase schema and RLS setup in `docs/supabase-workflow-history.sql`. + +## Day 20 (parallel track 2): History Page and Supabase Bootstrap + +- Added a dedicated `History` page in the Streamlit navigation. +- Centralized authenticated history refresh so sign-in and session restore load the same recent workflow and artifact state. +- Added `docs/supabase-bootstrap.sql` as a one-shot setup path for `app_users`, `usage_events`, `workflow_runs`, and `artifacts`. +- Updated README setup guidance to reflect the working Supabase-backed auth, quota, and history path. + +## Day 21: Saved-Run Regeneration and History-State Separation + +- Extended `workflow_runs` to persist saved reconstruction payloads: + - `workflow_signature` - `workflow_snapshot_json` + - `report_payload_json` + - `tailored_resume_payload_json` +- Added historical regeneration helpers so saved reports, tailored resumes, PDFs, and ZIP bundles can be rebuilt from persisted payloads without re-running OpenAI. +- Separated history selection state from the active current workflow run so new exports do not attach to an older historical run by mistake. +- Added additive Supabase migration support in `docs/supabase-workflow-history-payloads-migration.sql`. +- Verified the history-regeneration path with focused tests and a passing full suite. + +## Day 22: Documentation Re-Baselining + +- Rewrote the architecture, strategy, roadmap, and README narrative so the published repo matches the implemented product. +- Documented the current operating model more clearly: + - Streamlit-first UI shell + - supervised specialist-agent workflow + - Supabase-backed auth, quotas, and history + - saved-payload historical regeneration instead of blob storage +- Cleaned up stale product-copy references that still described persistence and history as future work. + +## Day 23: Payload Versioning and History UX Tightening + +- Wrapped new saved workflow payloads in a versioned JSON envelope while keeping the historical reader backward-compatible with the earlier unversioned payload format. +- Added compatibility inspection so unsupported or malformed saved payloads fail visibly in the History page instead of silently producing incorrect downloads. +- Clarified quota UX by separating account-level daily quota messaging from browser-session safeguards. +- Made the History page more explicit that browsing old runs is read-only and does not retarget new exports away from the current active workflow run. + +## Day 24: Pre-Deployment Hardening and Hosting Decision + +- Split the remaining large UI workflow and page boundaries behind stable facades while preserving the public entrypoints. +- Extracted duplicated builder helpers into shared utilities and centralized UI-side `AuthService` access. +- Cleaned the remaining pre-launch hygiene items: + - removed unused helper code + - consolidated duplicate string-list normalization logic + - replaced `datetime.utcnow()` with a timezone-aware UTC clock path + - documented the ReportLab `md5` compatibility patch +- Expanded boundary coverage for fit, job, tailoring, strategy, and logging modules. +- Added `.streamlit/config.toml` and reworked deployment docs so the app can be deployed before Supabase is provisioned. +- Chose **Streamlit Community Cloud** as the first deployment target while keeping the existing Playwright/Chromium-first PDF path and retaining ReportLab as the runtime fallback. + +## Day 25: Supabase Auth Stabilization and Local Operator Setup + +- Added repo-root `.env` loading for local development while preserving hosted secret-manager compatibility through `os.getenv(...)`. +- Added `docs/supabase-setup-checklist.md` as the canonical fresh-project operator guide for Supabase setup. +- Stabilized Supabase Google sign-in for the Streamlit rerun model by preserving PKCE verifier state across the OAuth redirect and callback exchange. +- Fixed the sidebar navigation handoff so JD-page transitions no longer mutate `current_menu` after the radio widget is instantiated. +- Removed stale fresh-install guidance that still pointed at the earlier workflow-history migration file. +- Verified the auth and navigation changes with focused tests and a passing full suite. + +## Day 26: OpenAI Runtime Hardening and Reasoning Routing + +- Diagnosed assisted-workflow fallback to a GPT-5 compatibility issue in the Responses API path: routed models were rejecting `temperature`. +- Updated `src/openai_service.py` to retry without `temperature` when the routed model rejects that parameter. +- Added a retry path for incomplete Responses API outputs caused by exhausted `max_output_tokens`. +- Increased the OpenAI client timeout and enabled SDK retries to reduce transient `read operation timed out` failures in the assistant path. +- Added per-task GPT-5 reasoning routing: + - medium effort for normal workflow tasks + - high effort for review, resume generation, and grounded application-QA tasks +- Extended `.env.example` and config helpers so reasoning effort can be tuned without editing code. +- Verified the stabilized OpenAI path with targeted service tests, live local probes, and a passing full suite. + +## Day 27: Saved Workspace Retention Hardening + +- Removed the legacy `workflow_runs` and `artifacts` persistence path so the product now stores only one latest `saved_workspaces` snapshot per user. +- Simplified runtime config, state, exports, and tests around the latest-only saved workspace model. +- Updated the Supabase bootstrap so expired saved workspaces become unreadable exactly at `expires_at` through RLS. +- Added a Supabase scheduled cleanup job that deletes expired saved-workspace rows every 5 minutes, even if the user never returns. +- Kept the app-side save/load purge as a backup cleanup path in case the scheduled job is temporarily unavailable. + +## Day 28: Saved Workspace UX Simplification And Doc Re-Baseline + +- Removed the dead dedicated saved-workspace page and its unused test path because the product now restores the latest saved snapshot only through the sidebar `Reload Workspace` action. +- Removed history-only helper paths that were left behind from the earlier `workflow_runs` era. +- Updated the assistant and retrieved product knowledge so they describe the current reload flow accurately and no longer mention the removed page or the old live `ProfileAgent` / `JobAgent` path. +- Re-baselined the active docs around the real shipped state: + - login-first resume intake + - no separate history tab + - one latest reloadable saved workspace per user + - current Render + Docker + Supabase deployment path +- Removed broken README/checklist references to docs that are no longer in the repo. + +## Day 29: Render Auth Stabilization And Saved Usage Refresh + +- Stabilized Google sign-in on Render across the Supabase callback flow. +- Fixed PKCE callback persistence issues caused by the hosted Streamlit redirect/runtime model. +- Fixed the sign-in button navigation regression after the callback hardening changes. +- Added a server-side fallback path so the auth code exchange no longer depends only on a returned custom query parameter. +- Fixed same-session quota refresh behavior after successful assisted workflow runs so the sidebar reflects updated account state without requiring a fresh login. + +## Day 30: Login-Required AI Features And Quota Simplification + +- Made the in-app assistant login-required in the active product. +- Simplified quota UX so the product now presents account-level daily quota as the main user-facing assisted limit. +- Removed browser-session assisted budget from the live UI and current product copy. +- Re-aligned assistant fallback behavior, product knowledge, prompt guidance, and README language around the authenticated quota model. + +## Day 31: Public Repo Cleanup And README Rebuild + +- Rebuilt the public README around the live product: + - badges + - Render app link + - screenshot story + - sample rendered PDF links +- Added the screenshot and rendered-PDF assets to tracked repo content for GitHub presentation. +- Moved tracked demo resume PDFs under `static/demo_resume/` so demo assets live with the app inputs instead of under old PDF-template docs. +- Re-baselined the roadmap around: + - finishing the job-application product + - hardening the current Render-hosted Streamlit stack + - later FastAPI + Docker backend extraction + +## Day 32: FastAPI Job Backend Foundation + +- Added an in-repo FastAPI backend skeleton under `backend/` with: + - `/api/health` + - `/api/jobs/search` + - `/api/jobs/resolve` +- Added shared job-search schemas and provider boundaries for backend-owned job discovery. +- Wired the first real provider path through Greenhouse board/job resolution. +- Added deterministic Greenhouse normalization and imported-job review rendering in the JD flow. + +## Day 33: Multi-Provider Search And JD Review Expansion + +- Added Lever as provider `#2` behind the same adapter contract as Greenhouse. +- Turned `Job Search` into a real backend-powered search surface instead of a placeholder. +- Added recent-first search ordering and stronger role-family matching for technical roles. +- Extended the JD review panel so manual and imported JD flows both render readable summaries before analysis. +- Persisted imported job metadata inside the saved-workspace snapshot so `Reload Workspace` restores the full imported-job context. + +## Day 34: Saved Jobs Shortlist And Search UX Polish + +- Added a Supabase-backed `saved_jobs` persistence layer for shortlisted jobs. +- Added save/remove actions directly on job-search result cards for authenticated users. +- Added a `Saved Jobs` panel on the Job Search page so shortlisted roles can be revisited and loaded back into the JD workflow later. +- Added deterministic in-card job preview rendering so users can inspect skills, compensation, location, and structured summary before import. +- Polished result-card clarity around remote/location signals and saved-state visibility. + +## Day 35: Assistant Session Memory And Latency Reduction + +- Kept one visible in-app assistant chat while splitting the internal task routing between: + - lighter product-help questions + - stronger grounded application-QA questions +- Reduced assistant prompt weight by replacing oversized workflow payloads with a compact package-context summary for application questions. +- Added short-lived assistant session memory on top of the OpenAI Responses API: + - prewarm assistant context when the panel opens + - store the latest `response_id` + - reuse that conversation state for follow-up questions during the same session +- Added assistant session signatures so the app clears stale assistant memory automatically when the relevant workflow context changes. +- Kept the behavior defensive: + - single chat UX remains unchanged + - deterministic fallback still works when assisted execution is unavailable + - clearing chat also clears the short-lived assistant session memory +- Verified the assistant pass with focused assistant-service and assistant-panel tests. + +## Day 36: Next.js + FastAPI Re-Baseline + +- Completed the architecture migration from the old Streamlit runtime to the live Next.js + FastAPI split stack. +- Removed the retired Streamlit shell, deployment files, and Streamlit-only tests from the active repo. +- Moved the product onto the Vercel frontend plus VPS backend deployment shape. +- Reworked the workspace UI around the real product flow: + - upload profile + - search job + - review job description + - run the workflow +- Simplified the visible outputs so the workspace now centers on: + - tailored resume + - cover letter +- Removed the visible application report from the workspace. +- Removed the strategy stage from the active agentic workflow. +- Simplified resume export to one standard ATS-friendly format and removed the old modern resume theme path from the active backend and frontend. +- Re-baselined the README and architecture docs so they reflect the shipped Vercel + FastAPI product rather than the earlier Streamlit stages. + +## Day 37: Workspace "Workbench" Redesign + +- Rebuilt the workspace UI as Direction B "Workbench": + - top bar with brand + ⌘K command-palette trigger + account popover + - four-step rail (Resume → Job Search → Job Detail → Analysis) with explicit gating and done/active visual states + - hero band with dynamic per-tab title, sub, and status pill + - vertical canvas of regions instead of the previous left-sidebar split + - floating assistant FAB replacing the side-mounted assistant column +- Added a ⌘K command palette overlay for fast navigation between workspace surfaces. +- Pulled all three resume-builder review steps into auto-growing textareas inside collapsible sections so editing long responses no longer scrolls inside a tiny box. +- Reset the resume-builder intake mode on commit and added name-pending fallbacks so the first auto-summary doesn't crash when the LLM hasn't extracted a full name yet. +- Lifted workspace base font sizes and chip / button metrics for readability on standard 1080p displays. +- Capped canvas width and restructured the analysis pipeline grid so the workspace stays comfortable on wide screens; collapsed the resume intake card on parse so the next step gets the focus. +- Tightened the assistant FAB surface (deeper black-blue background + full-width replies) so the chat panel stops fighting the underlying canvas. +- Polished the "honest hero" copy, vertical skills/experience layout, friendlier pipeline labels, empty-state hints, and a next-step pulse. +- Fixed two regressions caught during the redesign: + - workflow-completion notice no longer leaves the analysis card stuck on "Running" + - landing "Sign out" button no longer flips to "Signing out…" when the user clicks "Enter workspace" + +## Day 38: Atmospheric Polish, Mobile Responsive Pass, And Parser Quality Lift + +- De-boxified the editorial document treatment for the Draft profile + JD body: tighter type pairing, removed the per-section card chrome, treated the canvas like one document. +- Atmospheric polish across the workspace — page grain texture, layered surfaces, richer hover and focus states. +- Motion + delight pass: per-region entrance animations, subtle micro-interactions, count-up animations on quota and saved-job stats. +- Replaced the four-button rail with a unified pill nav that ships a progress connector and per-step lock-reason tooltips. +- Single 540px-breakpoint mobile responsive pass covering the topbar, hero, rail, regions, account popover, intake mode toggle, pipeline cards, and chip wrap. Brand text reflows correctly on narrow screens. +- Resume rendering robustness: + - drop empty resume sections cleanly (Experience can drop for student / early-career profiles, only Certifications drops in the standard case) + - added Projects + Publications as first-class resume sections; un-clipped page-2 overflow on the PDF render + - matched the resume PDF + parsed-view typography to the cover-letter family + - added a switchable `professional_neutral` theme alongside `classic_ats` (Georgia body, neutral grays — pure black/white aesthetic for editorial-leaning profiles) +- Resume parser quality lift: + - hardened the deterministic resume parser; routed TXT through the LLM hybrid + - expanded the parser-quality test set from 6 → 15 fixtures across unseen formats + - simplified the hybrid to a pure LLM source-of-truth with a full deterministic fallback when the LLM is unavailable or schema-fails + - deterministic polish lifted average from 0.81 → 0.92 across the 15 fixtures +- Added a Tier-2 renderer-fidelity quality runner; fixed a double-escape on experience meta lines along the way. +- JD parser quality lift: + - Tier-1 baseline: 15 fixtures, deterministic 0.78 average + - LLM JD parser: 0.78 → 0.99 across the same fixture set +- Added skill canonicalization so Postgres / PostgreSQL synonyms collapse during fit matching — stops the false-negative skill gaps users were seeing. +- Workflow narrowing: + - removed FitAgent, the application-package report, and the bundle endpoint from the active workflow + - TailoringAgent battle-test: 0.99 average across 6 (resume, JD) pairs + - ReviewAgent battle-test: 1.00 LLM, 0.69 deterministic across 6 scenarios +- Per-profile resume section ordering: students lead with Education, academics with Publications, seniors with Experience after Skills. Drives both the HTML and the PDF templates. +- Fixed a resume-builder review-progress bug where a re-uploaded basics block over-captured roles + dropped review progress. + +## Day 39: DOCX Export, Conversational Resume Builder, And Cached-Jobs Foundation + +- Workspace auth gate: signed-out visitors hitting `/workspace` are now redirected to the landing page; cross-origin host strip mirrors the existing app-subdomain middleware (no new env var). +- Resume-builder durability: + - 7-day TTL on `resume_builder_sessions` with active-user refresh, mirroring the `saved_workspaces` TTL pattern; cron + RLS expires-at filter both wired + - tri-state persistence indicator (saved / skipped / unauthenticated) in the field-completeness rail so the user knows whether their progress will survive a reload +- DOCX-first artifact export pipeline (six phases): + - Phase 1: `python-docx`-based exporter for the `classic_ats` theme; mirrors the existing structured PDF render (header, summary, skills, experience, projects, education, publications, certifications) and honours `artifact.section_order` + - Phase 2: artifact-export route now dispatches on `pdf | docx`; the markdown branch is removed from `backend/services/artifact_export_service.py` + - Phase 3: frontend cleanup sweep — removed every Markdown export button, hook, and type; download buttons now offer PDF and DOCX side-by-side + - Phase 4: `professional_neutral` DOCX theme with a shared palette resolver across PDF and DOCX so both themes read consistently in Word, Google Docs, and the PDF preview + - Phase 5: `POST /workspace/resume-builder/export` synthesizes a `TailoredResumeArtifact` from the builder session's draft profile (no JD, empty `target_role`, `section_order` from `compute_section_order(candidate_profile)`); auth-gated like the other resume-builder routes + - Phase 6: download row UI under "Generate base resume" — theme picker + Download PDF / Download DOCX +- Conversational LLM resume builder: + - shipped the 14-item punch list (DB migrations, lazy-load, thread-bound state, all three battle tests, adversarial coverage, signature hash, dead-code cleanup) + - end-to-end LLM chat: 5/8 fields extracted in one turn, backtracking works, 100% completion on the smoke fixture; "Generate base resume" produces clean DOCX/PDF + - workspace chat-bubble experiment shipped + reverted; transcript style retained as the chosen direction +- Resume-builder content quality: + - LLM-first structuring pass with a deterministic regex fallback, plus header alignment so the rendered name matches the structured schema + - skills are bucketed into named categories (`Languages & Tools`, `ML/DL Frameworks`, etc.) so the rendered resume groups skills by family instead of a flat pipe-separated list + - structuring output cached across exports + persistence so re-rendering doesn't re-run the LLM + - recovers a full name when the LLM intake drops a surname mid-conversation + - thin one-liner summaries get expanded to full paragraphs by the structuring pass; bumped the structuring model + token budget for the expanded contract + - Projects + Publications sections rendered through the same Draft profile / DOCX / PDF path as Experience + - Tier-3 quality runner for the resume-builder structuring pass +- ResumeGenerationAgent battle-test: LLM 1.00, deterministic 0.94 across 6 (resume, JD) pairs. +- CoverLetterAgent battle-test: LLM 0.97, deterministic 0.95 across 6 pairs. +- Cached-jobs foundation (Phases 2 + 3 of the seven-phase plan): + - Phase 2: `cached_jobs` Supabase table + `refresh_cached_jobs` worker; `POST /admin/refresh-cache` endpoint protected by a constant-time bearer compare. Worker bulk-upserts every Greenhouse + Lever posting and runs the smart cleanup (tombstone if a user has saved the listing, hard-delete otherwise) per source — only sources whose refresh actually succeeded are eligible for cleanup. + - Phase 3: `/jobs/search` defaults to the cached path through `JobSearchService.search_cached(...)`; `?live=true` keeps a live-fan-out escape hatch for diagnostics. Surfaces `cache: ok | not_configured | error` in `source_status` so monitoring can see when the cache misses. + +## Day 40: Multi-ATS Coverage, Postgres-RPC Ranked Search, And Dropdown Filters + +- Phase 4: bumped the source pool to ~117 Greenhouse boards + 30 Lever sites and validated the slug list against the live APIs. First refresh after deploy hits the cache rather than every user paying the live fan-out cost. +- Phase 5: enabled `pg_net` in the Supabase project + documented the cron schedule that POSTs to `/admin/refresh-cache` every ~30 min (committed under `docs/job_cache_cron_setup.sql`). Frontend gets an "Expired" badge on saved-job cards whose listings the cleanup pass has tombstoned. +- Phase 5b: relevance-ranked cache search via a new Supabase RPC (`search_cached_jobs_ranked`): + - PostgREST's `text_search()` chain returns a terminating builder that doesn't compose with `.order()`, so a single round-trip ranked search needs a function. The RPC owns the FTS + filter + sort logic and `CachedJobsStore.search()` calls it with a stable kwarg dict. + - Warm cache: ~360 ms; cold: ~5.5 s; vs ~25 s for the live fan-out — the cache layer paid for itself on the first user query. + - Post-flight fixes for cleanup eligibility and the report shape. +- Phase 6: re-validated and expanded the source list — final Greenhouse pool of 79 verified boards + Ashby adapter (36 boards). Composite job IDs (`source:tenant:job_id`) avoid cross-tenant collisions when one company runs multiple Ashby boards. +- Phase 7: Workday adapter for 11 Fortune 500 tenants (NVIDIA, Adobe, Walmart, Disney, HP, HPE, Boeing, Citi, Micron, BlackRock, Workday itself). Per-board page delay + reduced concurrency to stay under the anti-bot threshold; production cadence (one refresh per ~30 min) sits well below the rate limit. Fixed a status-reporting bug along the way: an all-failed provider used to land in the report as `status: ok` because the only path that set status away from `ok` assumed `boards_succeeded > 0`. +- Phase 8: dropdown filters + sort for job search: + - schema: `work_mode` and `employment_type_norm` GENERATED STORED columns on `cached_jobs` (with partial indexes on `removed_at IS NULL`); intern detection uses Postgres word-boundary regex (`\mintern(s|ship|ships)?\M`) so "Internal" / "International" don't false-match + - RPC v2: extends `search_cached_jobs_ranked` with `p_work_modes`, `p_employment_types`, `p_sort_by`; ORDER BY branches on the sort key (`relevance` → `ts_rank` when there's a query else recency, `newest` → `posted_at DESC`, `oldest` → `posted_at ASC`, `company_az` → `LOWER(company)`) + - Python plumbing: `JobSearchQuery` + `JobSearchRequestModel` + `CachedJobsStore.search()` extended with the new args; Pydantic validators normalize input + coerce unknown sort values to `relevance` + - Frontend: replaced the lone "Remote only" checkbox with five dropdowns — Source / Work mode / Type (multi-select), Posted within (single-select, retained), Sort (single-select, new). Multi-select chips built on native `
`/`` for keyboard accessibility plus an extra `mousedown` outside-click + `Escape` dismiss handler so the popover behaves like a native menu. + - Verified end-to-end against the live cache: filtering by Source = greenhouse + lever, Work mode = remote, Sort = company A → Z returned 12 alphabetically-sorted Pinterest-then-Affirm matches, all remote-friendly. +- Total active cache after Day 40: ~11,877 jobs across four ATS providers. + +## Day 41: Landing Polish, Independent Step Navigation, Assistant State-Awareness, And Multi-Layer LLM Retry + +### Landing redesign — final polish pass + +- Workbench scroll narrative iteration: shrunk the sticky visual stage from a stretched 480 × 853 to a square 480 × 480 (aspect-ratio 1/1) with center-pinning so empty space inside the stage stops at ~60–100 px instead of the previous 300+ px. +- Each of the four mock cards now mirrors the real workspace page rather than a generic data card: + - Step 01 Resume: parsed-profile hero (Aria Patel · Staff ML Engineer · San Francisco) + 3-up stats grid (12 roles · 27 skills · 9 yrs) + skills chip cluster + filename pill with a green `PARSED` tag. + - Step 02 Job Search: search bar with location, four filter chips, "47 MATCHES · BY RELEVANCE" header, three result cards with a gold "★ TOP MATCH" badge on the leader. + - Step 03 JD: three big metric tiles (Match score 87%, Hard skills 12, Years 5+) with a blue-tinted accent on the match-score card, plus hard/soft skills chip rows. + - Step 04 Analysis: four agent pipeline cards (Matchmaker ✓, Forge ✓, Gatekeeper running 62% with progress bar, Cover letter agent ○ standby). +- Step text is now `justify-content: center` inside each 48vh block so step 01 reads at viewport center on first scroll-in, aligning with the centered visual stage. +- Bento carousel tiles + workbench mock card surface dropped the previous blue corner-glow radial in favor of a flat `rgba(0, 0, 0, 0.40)` overlay that matches the workspace's `.b-jd-block` treatment — landing and workspace now read as one surface family. +- Topbar consolidated to `Workflow · Features · [Auth]` — dropped the third GitHub link (already covered by the hero CTA + footer link). +- Extracted the landing page into a design-system reference at `frontend_redesign/redesign/landing/` (README + 5 specs covering chrome, hero, workbench, bento, final CTA) — peer to the existing workspace `handoff/` so future passes have a same-shape context bundle. + +### Independent step navigation in the workspace + +- Removed the resume-parse gate on Job Search and Job Detail. A user can now paste a JD they're curious about before they have a resume, or browse listings without uploading anything. The "Upload a resume to unlock" tooltips on the rail are gone. +- Only Analysis stays gated (it can't run without both inputs). The page-level "Upload a resume to proceed" affordance inside `AnalysisRunner.tsx` already enforces this honestly. +- Cleaned up the now-dead "Upload a resume first" fallback `sub` text on the `nav-jobs` and `nav-jd` command-palette entries. + +### Assistant chat — ungated and state-aware + +- Removed the analysis-required gate that locked the assistant chat until a workspace had been analyzed. Users can now ask product-help questions ("how do I use this?", "what's step 03 for?") from the very first visit. + - Three gates lifted in one pass: the panel's footer "Assistant unlocks after your first workspace run" lockup, the `submitAssistantQuestion` early-return + warning notice, and the `assistantUnlocked` prop on the command palette (now always true). + - Renamed the cosmetic prop from `requiresWorkspaceRun` → `hasWorkspaceContext` so the panel adapts copy (header sub, empty state, textarea placeholder) based on whether a workspace exists, not whether the chat is locked. +- Added a `WorkspaceStateContext` projection that rides on every assistant query — `current_step`, `has_resume`, small `resume_summary`, `has_jd`, small `jd_summary`, `has_analysis`, `saved_jobs_count`, `last_search_query`. Counts only, no raw resume text. Backend's `WorkspaceStateContextModel` validates it; service layer folds it into the `app_context` dict that reaches `AssistantService`. +- Added a 9-rule `_WORKSPACE_STATE_GUIDANCE` block to both the JSON-contract (`build_assistant_prompt`) and the streaming prose (`build_assistant_text_prompt`) system prompts so the LLM knows the shape of the new field, the step-number mapping (01=Resume, 02=Job Search, 03=Job Detail, 04=Analysis), the auth contract (signed-out users get redirected to landing — there's no "use feature X without signing in" answer), and the field semantics (e.g. `experience_entries_count` is the count of jobs held, NOT years). +- Battle-tested across three personas (cold start / mid-flow / ready-to-run) over three rounds: + - Round 1: 22/24 passes; surfaced two bugs (entry-count read as years, step-03 mismatch). + - Round 2: 13/15 passes after the first two fixes; surfaced a product-knowledge gap (the "assistant builder" mode wasn't in the retrieval index) and a "yes you can analyze signed-out" mistake. + - Round 3: 12/12 passes after refreshing `src/product_knowledge.py` to ground truth (12 documents covering auth, the 4-step flow, resume intake modes, all four ATS sources, supervised pipeline agents, exports, saved workspace, command palette, the assistant FAB, cover letter, quotas). + - Combined: 47/51 (92%) with 0 outstanding correctness failures. + +### LLM resilience — three-layer retry stack + per-agent fallback isolation + +The orchestrator's previous behavior was all-or-nothing: any single agent failure (after the SDK's built-in retries) cascaded to "downgrade the WHOLE pipeline to deterministic." A single bad packet during the Forge agent meant Gatekeeper, Builder, and Cover letter all ran deterministic too. Reworked the resilience layer: + +- **Layer 1 (existing):** OpenAI Python SDK retries up to 2 times on transient HTTP / 5xx / 429-with-Retry-After (we set `max_retries=2` on the client). +- **Layer 2 (new):** App-level retry on top of the SDK. After the SDK exhausts its 2, we try ONE more time on a tight allow-list — `APIConnectionError`, `APITimeoutError`, `InternalServerError`. NOT for 4xx / auth / persistent rate-limit / content-policy (deterministic problems). 400 ms delay between attempts. New `openai_request_app_retry` log event for production observability. +- **Layer 3 (new):** Per-agent retry inside the orchestrator. If an agent's `.run(...)` raises `AgentExecutionError` (e.g. all OpenAI-call retries exhausted, or the response was semantically broken even after the existing budget retry), we wait 400 ms and retry that agent's full run once. Only fires in `mode="openai"`; no-op in deterministic. +- **Per-agent fallback isolation (new):** When an agent's two LLM attempts both fail, the orchestrator runs that agent's deterministic fallback (via `AgentClass(None).run(...)`) for THAT agent only — downstream agents still try the LLM path. Forge failing no longer affects Gatekeeper. + - Each call site now passes a `deterministic_fallback_runner` lambda alongside the assisted runner. + - The whole-pipeline fallback is now a safety net that fires only if a per-agent deterministic fallback ITSELF errors out (very unusual — would mean our own deterministic code is broken). + - Added a mode-reconciliation pass: if a pipeline started as `mode="openai"` but every agent ended up falling back per-agent (zero LLM successes), `result.mode` flips honestly to `deterministic_fallback` and the first LLM error's user_message becomes the `fallback_reason`. + +Worst-case retry budget for a transient failure: SDK 2 + app 1 + per-agent 1 = up to 4 effective LLM attempts before an agent gives up. After that, that agent's deterministic fallback runs and the rest of the pipeline keeps using the LLM. + +Coverage check: every `responses.create` call in the codebase routes through the new `_create_response_with_app_retry` helper now (`run_json_prompt`, `run_text_stream`, and the existing output-budget retry helper). By extension, the resume parser, JD parser, JD summary, all four workflow agents, AND the assistant chat all inherit the new retry layer for free. + +Tests: 17 new resilience tests pin the contracts — +- 9 in `tests/test_openai_app_retry.py`: retries on the 3 allow-listed types, does NOT retry on 4xx/auth, returns success after retry, raises on double-failure. +- 8 in `tests/test_orchestrator.py` (5 existing + 3 new): per-agent retry recovers, per-agent fallback isolates a single failing agent, full-pipeline mode flips to deterministic when no agent succeeded with LLM. + +### ADRs added + +- [ADR-017: Workspace assistant — ungated + state-aware context](docs/adr/ADR-017-workspace-assistant-state-aware-context.md) +- [ADR-018: Three-layer LLM retry + per-agent fallback isolation](docs/adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md) +- [ADR-019: Independent step navigation in the workspace](docs/adr/ADR-019-independent-step-navigation.md) + +## Day 42: Tier Enforcement — Quota Counters, Caps, And Premium Model Routing + +Eight-step series shipped across `feat/tier-enforcement` and merged + deployed (commits `ff2fe2d` through `0ede6ea`). Until now the product had a single `usage_events` daily-quota path inherited from the Streamlit era — a per-day cap on assisted requests with no notion of subscription tiers, no per-action gating, and no separate premium pathway. Day 42 lands the full tier-enforcement matrix end-to-end. Today every quota gate routes through one cap table; payments will land in a separate week (see Day 43) and flip a single function body. + +### Eight logical steps + +1. **Tier shim + cap matrix** (`ff2fe2d`) — `backend/tiers.py` introduces `resolve_user_tier(app_user) -> Literal["free", "pro", "business"]` (returns `"free"` for everyone today, the single function body to swap once subscriptions go live) and the `TIER_CAPS` table covering eight counters: `tailored_applications`, `premium_applications`, `resume_builder_sessions`, `assistant_turns`, `resume_parses`, `job_searches`, `saved_jobs`, `saved_workspaces`. `UNLIMITED = -1` is the no-cap sentinel. +2. **Atomic check-and-increment** (`b2a4947`) — `aijobagent_quota_counters` table + `increment_aijobagent_counter` RPC in `docs/sql/supabase-quota-counters.sql`. The RPC does INSERT-ON-CONFLICT inside `FOR UPDATE`, raises SQLSTATE `P0001` with detail `aijobagent_quota_exceeded` on overrun. `backend/quota.py::check_and_increment` translates the P0001 into `QuotaExceededError`, surfaced as a uniform 429 via the single global handler in `backend/app.py`. Concurrent workspace runs from the same user produce N+1 and N+2 — never both N+1. Refund-on-failure (`backend.quota.refund`) decrements by 1 (floored at zero) from the workflow-failure path so a transient orchestrator error doesn't burn a credit. +3. **Workspace gates: tailored + premium applications** (`6e893e6`) — both counters wired at `/workspace/analyze`. Free-tier premium=True is rejected with a tier-specific message ("Premium applications are a Pro+ feature.") before any agent runs. +4. **Workspace gates: assistant turns + resume parses + resume-builder sessions** (`2dc76cd`) — three more gates with a special case: `assistant_turns` is gated on the streaming SSE path *and* the non-streaming JSON path separately so SSE clients can't sneak past by reconnecting mid-flight. `resume_builder_sessions` uses the new `lifetime=True` kwarg on Free (lifetime period_key, cap 1) and the standard monthly partition on Pro/Business (cap 3 / 15). +5. **Search + saved gates with persistent row-count counters** (`d249b28`) — `job_searches` (monthly) plus `saved_jobs` and `saved_workspaces` (persistent caps backed by the corresponding store's row count, not the counter table). The persistent counters bypass `check_and_increment` entirely on the read path; `/workspace/quota` reads row counts directly from `SavedJobsStore` / `SavedWorkspaceStore`. +6. **Tier-aware model routing** (`68be1d5`) — `backend/model_routing.py::select_workflow_model` returns `gpt-5.5` for `review` / `resume_generation` / `cover_letter` when `(premium=True, tier in {pro, business})` and `None` (= use the standard `OPENAI_MODEL_ROUTING[task]`) otherwise. Tailoring stays on `gpt-5.4-mini` regardless — COGS analysis pinned the upgrade to the three "high-trust" agents only, and keeping tailoring on mini is the difference between premium being sustainable and not. +7. **`/workspace/quota` endpoint + frontend Premium toggle** (`24a1840`) — read-only snapshot for the eight counters plus an `upgrade_url` field driven by `AIJOBAGENT_UPGRADE_URL`. Frontend renders a Premium toggle that's disabled+tooltip on Free without a second lookup (`premium_available` is True only on tiers with `premium_applications > 0`). +8. **Tier-aware saved-workspace retention sweeper** (`c57d658`, `0ede6ea`) — `backend/maintenance.py::sweep_expired_workspaces` deletes rows older than `retention_days_for_tier(tier)` (Free 7, Pro 30, Business None = unbounded). Replaces the legacy unconditional 24-hour sweeper that Supabase pg_cron had been calling. The supabase migration drops the legacy SQL function and hardens RPC grants so only `service_role` can call `increment_aijobagent_counter` (granting EXECUTE to `authenticated` would have let any signed-in user burn another user's quota by passing their UUID). + +### Tests + +99 new tier-enforcement tests across `tests/backend/test_tiers.py`, `test_quota.py`, `test_workspace_quota_enforcement.py`, `test_assistant_quota_enforcement.py`, `test_resume_quota_enforcement.py`, `test_search_and_saved_quota_enforcement.py`, `test_tier_aware_workflow_model.py`, `test_workspace_quota_snapshot.py`, `test_workspace_retention.py`. Includes refund-on-failure recovery, atomic concurrency under thread races, lifetime-vs-monthly period switching, P0001 → 429 translation, and Business `None`-retention skip behaviour. + +### Deploy status + +Merged to `main` and deployed end-to-end. Quota gates are live; every user currently resolves to `free` until the payment cutover. + +### ADRs added + +- [ADR-020: Tier resolution via a single shim function](docs/adr/ADR-020-tier-resolution-via-single-shim-function.md) +- [ADR-021: Atomic quota with refund-on-failure](docs/adr/ADR-021-atomic-quota-with-refund-on-failure.md) +- [ADR-022: Tier-aware model selection via constructor injection](docs/adr/ADR-022-tier-aware-model-selection-via-constructor-injection.md) + +## Day 43: Lemon Squeezy Payment Scaffold (Awaiting Variant IDs) + +Four commits on `feat/lemonsqueezy-integration` wire the end-to-end paid-tier path on top of the Day 42 enforcement layer. Ready to ship — only waiting on the LS dashboard's final Pro / Business variant IDs to flip live. Until then, every code path stays env-gated behind a "Coming soon" fallback so the production frontend keeps shipping without holding the LS account hostage. + +### Four commits + +1. **Subscriptions table + tier resolution swap** (`1b8cf95`) — `aijobagent_subscriptions` table holds one row per active or past subscription (`user_id`, `processor`, `processor_subscription_id`, `tier`, `status`, `current_period_end`, `created_at`, `updated_at`) with a partial unique index on `(user_id) WHERE status = 'active'` so a user has at most one active sub. `backend/subscriptions.py` is the thin store wrapper. Crucially, the body of `backend/tiers.py::resolve_user_tier` is updated to consult this store: if an active subscription exists whose `current_period_end > now()`, return its `tier`; else return `"free"`. **This is the one-function change that ADR-020 promised.** Every existing quota gate flips from gating Free to gating the user's real tier with zero call-site churn. +2. **HMAC-verified webhook endpoint** (`c3c3348`) — `POST /api/webhooks/lemonsqueezy` parses the LS event, verifies the HMAC-SHA256 signature from the `X-Signature` header against `LEMONSQUEEZY_WEBHOOK_SECRET` using `hmac.compare_digest`, and routes by `meta.event_name` to the subscription store: `subscription_created` / `subscription_updated` upsert by `processor_subscription_id`, `subscription_payment_success` bumps `current_period_end`, `subscription_cancelled` / `subscription_expired` mark `status = 'cancelled'` or `'expired'`. The variant_id → tier mapping reads from `LEMONSQUEEZY_VARIANT_PRO` / `LEMONSQUEEZY_VARIANT_BUSINESS`; unknown variants log a warning and 200-OK (LS retries 4xx, so silent ack on unknown variants prevents stuck retry loops on misconfiguration). +3. **Frontend Upgrade CTA + customer portal link** (`c3a80ea`) — the Premium toggle's "Upgrade" CTA now opens the LS hosted checkout for the relevant variant when `NEXT_PUBLIC_LEMONSQUEEZY_*` env vars are set; falls back to a "Coming soon" disabled-button-plus-tooltip when they're not. Customer portal link in the account popover routes to `customer.lemonsqueezy.com/billing/{customer_id}` for active subscribers (read from `aijobagent_subscriptions.processor_customer_id`). +4. **Env vars + setup walkthrough** (`a236c81`) — `.env.example` entries for `LEMONSQUEEZY_WEBHOOK_SECRET`, `LEMONSQUEEZY_STORE_ID`, `LEMONSQUEEZY_VARIANT_PRO`, `LEMONSQUEEZY_VARIANT_BUSINESS`, `NEXT_PUBLIC_LEMONSQUEEZY_STORE_ID`, `NEXT_PUBLIC_LEMONSQUEEZY_VARIANT_PRO`, `NEXT_PUBLIC_LEMONSQUEEZY_VARIANT_BUSINESS`. `docs/lemon-squeezy.md` walks through the LS dashboard setup (store creation, two variant rows, webhook URL pointed at `api.job-application-copilot.xyz/api/webhooks/lemonsqueezy`, secret rotation procedure) plus the Supabase migration step. + +### Architectural neutrality + +`aijobagent_subscriptions.processor` is a text column, not a Lemon Squeezy-specific enum. When a future Stripe + Razorpay path lands (see ADR-023), the same store handles both — each processor writes its own row, `resolve_user_tier` picks the row with the highest active tier. No table migration needed at processor #2. + +### Deploy status + +Branch is local; commits not yet pushed. Going live needs three things — the two LS dashboard variant IDs, the webhook secret pasted into the VPS env, and the `aijobagent_subscriptions` migration applied to the prod Supabase project. After that, removing the env-gated fallback in the frontend is a one-line change. + +### ADRs added + +- [ADR-023: Lemon Squeezy as Merchant of Record for v1](docs/adr/ADR-023-lemon-squeezy-merchant-of-record-for-v1.md) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9f3fb7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + PATH="/app/.venv/bin:${PATH}" + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + fonts-dejavu-core \ + fonts-liberation \ + libatk1.0-0 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libglib2.0-0 \ + libharfbuzz0b \ + libjpeg62-turbo \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libxml2 \ + libxslt1.1 \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY . . + +EXPOSE 8000 + +CMD ["sh", "-c", "uvicorn backend.app:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/README.md b/README.md index 004eb1e..65dca7d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ -# AI-Job-Application-Agent -“A modular AI-powered agent for job application automation: resume tailoring, job scraping, LinkedIn-to-resume builder, analytics, and more.” +# AI Job Application Agent + +[![CI](https://github.com/LEANDERANTONY/AI_Job_Application_Agent/actions/workflows/ci.yml/badge.svg)](https://github.com/LEANDERANTONY/AI_Job_Application_Agent/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Live App](https://img.shields.io/badge/Live%20App-Vercel-2563eb?logo=vercel&logoColor=white)](https://job-application-copilot.xyz/) + +**A grounded job-application copilot.** Search live listings across four ATS providers, paste a job description and see it parsed into hard / soft / must-have skills, and run a five-stage supervised pipeline that produces a tailored resume + cover letter — every claim anchored to evidence from the source resume. + +**Live:** [job-application-copilot.xyz](https://job-application-copilot.xyz) · **Workspace:** [app.job-application-copilot.xyz](https://app.job-application-copilot.xyz) + +![Architecture overview](docs/images/job-agent-architecture.svg) + +--- + +## Visual tour + +![Job Search step with 12 live matches and saved jobs drawer](docs/images/landing_jobsearch.png) + +| Step 01 — Resume builder (chat one into existence) | Step 03 — Job Detail (parsed JD with match score + skill chips) | +|---|---| +| ![](docs/images/landing_resumebuilder.png) | ![](docs/images/landing_jd.png) | + +| Output — `classic_ats` theme | Output — Cover letter | +|---|---| +| ![](docs/images/classic_resume_render.jpg) | ![](docs/images/cover_letter_render.jpg) | + +--- + +## What's actually inside + +| System | What it does | +|--------|--------------| +| **Live job search** | Cached index of ~12,000 open roles from Greenhouse, Lever, Ashby, and Workday — refreshed every 4 hours. Filter by company, work mode, role type, posted-within. Sort by relevance, recency, or alphabetical. | +| **Resume intake** | Upload PDF / DOCX / TXT, or chat one into existence with the conversational builder. Parsed into a normalized profile with skills, experience timeline, projects, publications, and certifications. | +| **JD review** | LLM-first JD parser with regex fallback. Surfaces hard skills, soft skills, and must-haves; shows match score against the loaded resume. | +| **Supervised pipeline** | Matchmaker → Forge (tailoring) → Gatekeeper (review) → Resume Generation → Cover Letter. Three-layer LLM retry stack with per-agent fallback isolation, deterministic floor on every stage. | +| **Artifact export** | Two themes (`classic_ats` for ATS parsers, `professional_neutral` for human readers) in DOCX or PDF. The same source data feeds either pathway. | +| **Grounded assistant** | Floating workspace chat with full context of the loaded resume, JD, analysis state, and saved jobs. Streams answers as they generate. | +| **Command palette** | `⌘K` / `Ctrl+K` from anywhere — jump between steps, load a saved job, re-ask a recent assistant question, or run the analysis. | +| **Tier enforcement** | Per-(user, period, counter) atomic quota gates on every gated action (tailored applications, premium applications, assistant turns, resume parses, resume-builder sessions, job searches, saved jobs, saved workspaces). Free / Pro / Business cap matrix. Premium opt-in routes review + resume-gen + cover-letter to `gpt-5.5` while keeping tailoring on mini for COGS reasons. Refund-on-failure so a transient workflow error doesn't burn a credit. | + +--- + +## How job discovery works + +The cached jobs layer lives in Postgres (`cached_jobs` table) and is refreshed by a scheduled worker that fans out across all four sources. Highlights: + +- **~117 Greenhouse boards** + **30 Lever sites** + **36 Ashby boards** + **11 Workday Fortune-500 tenants** in the active source pool. +- **4-hour refresh cadence** via `pg_net` cron triggering the `/admin/refresh-cache` endpoint (6 refreshes per day at 00:00 / 04:00 / 08:00 / 12:00 / 16:00 / 20:00 UTC). +- **Relevance-ranked search** through a Postgres RPC that combines query token coverage with recency. +- **Saved-jobs drawer** with a 24-hour TTL — bookmarks survive page reloads but expire if you don't act on them, with an `EXPIRED` badge so nothing silently disappears. + +See [ADR-013](docs/adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) and [ADR-014](docs/adr/ADR-014-postgres-rpc-for-ranked-search.md) for the load-bearing decisions. + +## How the supervised pipeline works + +`ApplicationOrchestrator._run_pipeline` runs five stages with progress callbacks, per-stage duration logging, JSON-contracted agent outputs, and per-agent fallback isolation: + +1. **Matchmaker** (deterministic) — `build_fit_analysis()` compares the candidate profile against the JD and produces matched / missing skills. +2. **Forge** (`TailoringAgent`) — rewrites the deterministic baseline into role-specific resume guidance. +3. **Gatekeeper** (`ReviewAgent`) — checks grounding, reports unsupported claims, and returns corrected tailoring when repairs are possible. +4. **Resume generation** (`ResumeGenerationAgent`) — builds the final tailored resume artifact from the reviewed output. +5. **Cover letter** (`CoverLetterAgent`) — runs only after review approval and produces a role-specific cover letter. + +Each agent follows the same operating shape: deterministic baseline first, LLM-assisted refinement second, structured JSON output, and a deterministic fallback when assisted execution is unavailable. **Per-agent fallback isolation** means a single failing agent falls back independently — the other three keep their LLM-quality output. See [ADR-018](docs/adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md) for the three-layer retry stack (SDK retry × 2 + app-level retry + per-agent retry). + +## How grounding works + +- Deterministic services build the candidate profile, JD summary, fit analysis, and first-pass tailored draft before the agent layer runs. +- `ReviewAgent` returns `grounding_issues`, `unresolved_issues`, `revision_requests`, and an optional `corrected_tailoring` payload. +- The orchestrator uses `corrected_tailoring` as the downstream source of truth when review repairs the draft. +- Cover-letter generation is gated on review approval. +- The fallback review path checks whether the output references missing hard skills that aren't evidenced in the source profile. + +## Engineering notes + +- **44 Python test files** cover parsing, normalization, fitting, tailoring, orchestration, builders, exports, auth, quotas, persistence, error handling, and the four ATS adapters. +- **Quality runners** in `tests/quality/` produce evidence for each LLM-driven stage (parser, tailoring, review, resume gen, cover letter, assistant, JD parser, latency baseline). +- **23 ADRs** in `docs/adr/` record the architectural decisions, including the Streamlit-first → Next.js + FastAPI transition (ADR-012), DOCX-first export (ADR-015), conversational builder (ADR-016), state-aware assistant (ADR-017), three-layer retry stack (ADR-018), independent step navigation (ADR-019), tier resolution shim (ADR-020), atomic quota with refund (ADR-021), tier-aware model selection (ADR-022), and Lemon Squeezy as Merchant of Record for v1 (ADR-023). +- **Architecture details** live in [docs/architecture.md](docs/architecture.md). + +## Deployment + +- `app.job-application-copilot.xyz` → Vercel-hosted Next.js workspace +- `api.job-application-copilot.xyz` → VPS-hosted FastAPI backend +- `frontend/` → Next.js + React 19 + Turbopack +- `backend/` → FastAPI + Uvicorn, async OpenAI client, Supabase Postgres +- `backend/vps/` → Docker Compose + Caddy bundle for the backend stack +- `src/` → shared Python core (orchestrator, agents, builders, schemas, services) diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..138b08a --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""FastAPI backend package for job search and future service extraction.""" diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..6234a61 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,98 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware + +from backend.config import get_backend_settings +from backend.rate_limit import limiter, rate_limit_exceeded_handler +from backend.routers.auth import router as auth_router +from backend.routers.billing import router as billing_router +from backend.routers.health import router as health_router +from backend.routers.jobs import admin_router as jobs_admin_router, router as jobs_router +from backend.routers.workspace import router as workspace_router +from src.errors import QuotaExceededError + + +settings = get_backend_settings() + +app = FastAPI( + title=settings.service_name, + version=settings.service_version, +) + + +@app.exception_handler(QuotaExceededError) +async def quota_exceeded_handler(_request: Request, exc: QuotaExceededError): + """Translate a `QuotaExceededError` into the canonical 429 payload. + + The error is raised from `backend.quota.check_and_increment` when + the atomic Supabase RPC reports a P0001 quota_exceeded condition. + Everything quota-related routes through this single handler so the + frontend renders a uniform upgrade nudge regardless of which gate + fired -- no per-endpoint duplication of the response shape. + + Body shape (locked by the brief): + { + "detail": , + "code": "tier_limit_exceeded", + "counter": , + "current": , + "cap": , + "reset_period": <"YYYY-MM" | "lifetime" | ...> + } + + Status 429 mirrors the rate-limit semantics: "you've consumed your + allowance for this window, retry later (or upgrade)." HelpmateAI + uses 402 Payment Required for the same concept; we go with 429 per + the brief because rate-limit middleware on Caddy / proxies already + treats 429 specially (Retry-After header propagation, log filters) + and we get that plumbing for free. + """ + return JSONResponse( + status_code=429, + content={ + "detail": exc.user_message, + "code": "tier_limit_exceeded", + "counter": exc.counter, + "current": exc.current, + "cap": exc.cap, + "reset_period": exc.reset_period, + "tier": exc.tier, + }, + ) + + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=list(settings.cors_allowed_origins), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/") +def root(): + return { + "status": "ok", + "service": settings.service_name, + "frontend_url": settings.frontend_app_url, + "health_url": f"{settings.api_prefix}/health", + } + + +app.include_router(health_router, prefix=settings.api_prefix) +app.include_router(jobs_router, prefix=settings.api_prefix) +app.include_router(jobs_admin_router, prefix=settings.api_prefix) +app.include_router(auth_router, prefix=settings.api_prefix) +app.include_router(workspace_router, prefix=settings.api_prefix) +# Billing routes (LS webhook + customer portal) live under the same +# api_prefix as everything else. Final paths: +# POST {api_prefix}/webhooks/lemonsqueezy +# POST {api_prefix}/billing/portal +# Register the full URL (incl. /api prefix) in the LS dashboard. +app.include_router(billing_router, prefix=settings.api_prefix) diff --git a/backend/auth_models.py b/backend/auth_models.py new file mode 100644 index 0000000..846fc83 --- /dev/null +++ b/backend/auth_models.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class GoogleSignInStartRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + redirect_url: str = Field(default="", max_length=500) + + @field_validator("redirect_url", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + +class GoogleSignInExchangeRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + auth_code: str = Field(min_length=1, max_length=4000) + auth_flow: str = Field(default="", max_length=120) + redirect_url: str = Field(default="", max_length=500) + + @field_validator("auth_code", "auth_flow", "redirect_url", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + +class WorkspaceHandoffStartRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_url: str = Field(default="", max_length=500) + + @field_validator("target_url", mode="before") + @classmethod + def _strip_target_url(cls, value): + return str(value or "").strip() + + +class WorkspaceHandoffExchangeRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + handoff_token: str = Field(min_length=1, max_length=200) + + @field_validator("handoff_token", mode="before") + @classmethod + def _strip_handoff_token(cls, value): + return str(value or "").strip() diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..d81649d --- /dev/null +++ b/backend/config.py @@ -0,0 +1,87 @@ +import os +from dataclasses import dataclass + +from src.config import ( + ASHBY_BOARD_TOKENS, + GREENHOUSE_BOARD_TOKENS, + JOB_BACKEND_BASE_URL, + LEVER_SITE_NAMES, + WORKDAY_BOARD_TOKENS, +) + + +@dataclass(frozen=True) +class BackendSettings: + service_name: str + service_version: str + api_prefix: str + backend_base_url: str + frontend_app_url: str + cors_allowed_origins: tuple[str, ...] + greenhouse_board_count: int + lever_site_count: int + ashby_board_count: int + workday_board_count: int + # Auth cookie scoping. Empty domain means "host-only" (correct on + # localhost, where landing+workspace share the same origin). In prod + # set AUTH_COOKIE_DOMAIN=.job-application-copilot.xyz so the cookie is + # valid on both the root and app.* subdomains. + auth_cookie_domain: str + auth_cookie_secure: bool + auth_cookie_samesite: str + + +def _parse_bool(value: str, default: bool) -> bool: + normalized = (value or "").strip().lower() + if not normalized: + return default + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default + + +def get_backend_settings() -> BackendSettings: + frontend_app_url = ( + os.getenv("FRONTEND_APP_URL", "http://localhost:3000").strip() + or "http://localhost:3000" + ) + raw_cors_origins = os.getenv( + "CORS_ALLOWED_ORIGINS", + "http://localhost:3000,http://127.0.0.1:3000", + ) + cors_allowed_origins = tuple( + origin.strip() + for origin in raw_cors_origins.split(",") + if origin.strip() + ) + + auth_cookie_domain = os.getenv("AUTH_COOKIE_DOMAIN", "").strip() + # Default secure=true so production setups don't accidentally ship + # plaintext cookies; flip AUTH_COOKIE_SECURE=false explicitly for + # local HTTP dev. + auth_cookie_secure = _parse_bool( + os.getenv("AUTH_COOKIE_SECURE", ""), + default=True, + ) + raw_samesite = os.getenv("AUTH_COOKIE_SAMESITE", "lax").strip().lower() + auth_cookie_samesite = ( + raw_samesite if raw_samesite in {"lax", "strict", "none"} else "lax" + ) + + return BackendSettings( + service_name="AI Job Application Agent Backend", + service_version="0.2.0", + api_prefix="/api", + backend_base_url=JOB_BACKEND_BASE_URL, + frontend_app_url=frontend_app_url, + cors_allowed_origins=cors_allowed_origins, + greenhouse_board_count=len(GREENHOUSE_BOARD_TOKENS), + lever_site_count=len(LEVER_SITE_NAMES), + ashby_board_count=len(ASHBY_BOARD_TOKENS), + workday_board_count=len(WORKDAY_BOARD_TOKENS), + auth_cookie_domain=auth_cookie_domain, + auth_cookie_secure=auth_cookie_secure, + auth_cookie_samesite=auth_cookie_samesite, + ) diff --git a/backend/maintenance.py b/backend/maintenance.py new file mode 100644 index 0000000..eba9781 --- /dev/null +++ b/backend/maintenance.py @@ -0,0 +1,324 @@ +"""Tier-aware retention sweeper for saved_workspaces (Step 8). + +The sweeper applies per-tier retention to the `saved_workspaces` +table. Today every user resolves to "free" (the shim still returns +free for everyone), so in practice the 7-day Free retention is what +fires; the Pro / Business branches are exercised by the tests against +a patched `resolve_user_tier` so the wiring is locked in for the +Stripe cutover. + +Retention table (locked by the brief): + free 7 days + pro 30 days + business unbounded (no deletion on age) + +Implementation notes vs HelpmateAI's `sweep_local_workspace_storage`: + * HelpmateAI's sweeper also cleans up FileStorage objects, orphan + upload paths, orphan index dirs, etc. AI Job Agent saved workspaces + are JSON blobs in a Supabase row -- there are no bucket objects or + on-disk files to chase, so this sweeper is a pure DELETE pass. + * We resolve each row's owner via `resolve_user_tier(app_user)` per + the brief, so a future Stripe-aware resolver doesn't need to be + revisited. App-user records ride in `aijobagent_app_users`; we + fetch the row by user_id, then hand it to the resolver. + * Service-role client only -- the sweeper bypasses RLS because it + crosses user_id partitions. Mirrors `CachedJobsStore`'s + service-role pattern. + +CLI entry point at the bottom mirrors HelpmateAI's +`if __name__ == "__main__": main()`. Operators (or a cron job in the +VPS docker-compose) invoke this directly. The function returns a +`SweepSummary` so the cron log carries a structured record of how +many rows were touched. +""" +from __future__ import annotations + +import json +import logging +import os +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from backend.tiers import Tier, resolve_user_tier, retention_days_for_tier +from src.config import ( + SUPABASE_SAVED_WORKSPACES_TABLE, + SUPABASE_SERVICE_ROLE_KEY, + SUPABASE_URL, +) +from src.schemas import AppUserRecord + + +try: # supabase is an optional dep in some test paths + from supabase import create_client as _create_supabase_client # type: ignore +except Exception: # pragma: no cover - defensive import + _create_supabase_client = None # type: ignore + + +logger = logging.getLogger(__name__) + + +# Auth-table name. The same constant lives in `src.app_user_store`; +# we recompute it here so the sweeper has no runtime coupling to the +# auth module (which pulls in supabase as well). When SUPABASE_APP_USERS_TABLE +# is renamed via env, this falls through to the default just like the +# auth module does. +_APP_USERS_TABLE = os.getenv("SUPABASE_APP_USERS_TABLE", "app_users").strip() + + +@dataclass +class SweepSummary: + """Per-run summary returned by the sweeper. + + `expired_workspaces_deleted` is the count of saved_workspaces rows + whose `updated_at` was older than the owner's tier retention and + that we actually deleted. `business_workspaces_skipped` is the + count of rows whose owner resolved to Business (None retention) + and were therefore exempted -- separated so operators can sanity- + check that Business retention is firing. + + `errors` is a count of rows we tried to process but couldn't + (missing user record, Supabase delete failure, etc.). Per-row + failures don't abort the sweep -- we want to make progress on + the rest of the table. + """ + + expired_workspaces_deleted: int = 0 + business_workspaces_skipped: int = 0 + rows_inspected: int = 0 + errors: int = 0 + + def to_dict(self) -> dict[str, int]: + return asdict(self) + + +def _parse_timestamp(value: Any) -> Optional[datetime]: + """Parse the row's `updated_at` (ISO 8601 string or datetime). + + Supabase returns timestamps as strings; the deserialization path + in some tests hands us a real datetime instead. Both branches + return a tz-aware UTC datetime so the cutoff math is uniform. + Returns None on parse failure -- the row gets skipped at the + call site. + """ + if value is None: + return None + if isinstance(value, datetime): + moment = value + elif isinstance(value, str): + if not value.strip(): + return None + try: + moment = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc) + + +def _service_role_client(): + """Build a service-role Supabase client or return None. + + The sweeper crosses user_id partitions, so it has to bypass RLS; + only the service role can do that. Returns None when the env + vars / supabase dep aren't configured -- the caller logs and + exits cleanly so a misconfigured cron doesn't crash on import. + """ + if not (SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY): + return None + if _create_supabase_client is None: + return None + return _create_supabase_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) + + +def _load_app_user(client, user_id: str) -> Optional[AppUserRecord]: + """Fetch the app_users row for a given user_id. + + Used by the sweeper to feed `resolve_user_tier`. We accept None + on missing/error -- the caller falls back to the Free retention + in that branch so a tombstoned auth row can't make a workspace + immortal. + """ + if not user_id: + return None + try: + response = ( + client.table(_APP_USERS_TABLE) + .select("*") + .eq("id", user_id) + .limit(1) + .execute() + ) + except Exception as exc: # noqa: BLE001 - boundary + logger.warning( + "sweep_app_user_lookup_failed user_id=%s error=%s", + user_id, + exc, + ) + return None + rows = getattr(response, "data", None) or [] + if not rows: + return None + first = rows[0] + if not isinstance(first, dict): + return None + # Reuse the dataclass for a faithful representation of the row. + # Field shape mirrors `_build_fallback_app_user_record`'s output. + try: + return AppUserRecord( + id=str(first.get("id", "") or ""), + email=str(first.get("email", "") or ""), + plan_tier=str(first.get("plan_tier", "free") or "free"), + account_status=str( + first.get("account_status", "active") or "active" + ), + ) + except Exception: # pragma: no cover - defensive + return None + + +def _delete_workspace(client, user_id: str, table_name: str) -> bool: + """Delete the saved-workspace row for `user_id`. Returns True on + success, False on failure (logged). The store upserts on user_id + so there's at most one row to delete per user.""" + try: + client.table(table_name).delete().eq("user_id", user_id).execute() + except Exception as exc: # noqa: BLE001 - boundary + logger.warning( + "sweep_workspace_delete_failed user_id=%s error=%s", + user_id, + exc, + ) + return False + return True + + +def _row_should_be_deleted( + *, + tier: Tier, + updated_at: datetime, + now: datetime, +) -> bool: + """Decide if a single row's age has exceeded its tier retention. + + Business tier (retention=None) always returns False -- workspaces + never auto-delete for unbounded retention. Capped tiers compute + `cutoff = now - retention` and return True when `updated_at <= cutoff`. + """ + retention_days = retention_days_for_tier(tier) + if retention_days is None: + return False + cutoff = now - timedelta(days=int(retention_days)) + return updated_at <= cutoff + + +def sweep_expired_workspaces( + *, + now: Optional[datetime] = None, + table_name: str = SUPABASE_SAVED_WORKSPACES_TABLE, + client=None, +) -> SweepSummary: + """Delete saved_workspaces rows older than their owner's tier + retention window. Returns a SweepSummary the caller can log. + + `now` and `client` exist as parameters for the test suite -- the + happy production path leaves them defaulted. `table_name` exists + so a future schema migration can run the sweep against a shadow + table without code change. + + The function is idempotent: a no-op call right after a real sweep + finds no rows to delete and returns zeros across the board. + """ + summary = SweepSummary() + sweep_now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + sweep_client = client if client is not None else _service_role_client() + if sweep_client is None: + logger.warning( + "sweep_skipped_no_service_role_client " + "url_configured=%s key_configured=%s table=%s", + bool(SUPABASE_URL), + bool(SUPABASE_SERVICE_ROLE_KEY), + table_name, + ) + return summary + + try: + response = ( + sweep_client.table(table_name) + .select("user_id,updated_at") + .execute() + ) + except Exception as exc: # noqa: BLE001 - boundary + logger.exception( + "sweep_list_failed table=%s error=%s", table_name, exc + ) + summary.errors += 1 + return summary + + rows = getattr(response, "data", None) or [] + for row in rows: + if not isinstance(row, dict): + summary.errors += 1 + continue + summary.rows_inspected += 1 + user_id = str(row.get("user_id", "") or "") + updated_at = _parse_timestamp(row.get("updated_at")) + if not user_id or updated_at is None: + summary.errors += 1 + continue + + # Tier resolution per the brief: load the auth row and hand + # it to the resolver. Returning None falls through to Free + # retention so a missing user record can't make a workspace + # immortal. + app_user = _load_app_user(sweep_client, user_id) + tier = resolve_user_tier(app_user) + + if retention_days_for_tier(tier) is None: + # Business tier -- skip on age. The row stays until the + # user explicitly deletes it. + summary.business_workspaces_skipped += 1 + continue + + if not _row_should_be_deleted( + tier=tier, updated_at=updated_at, now=sweep_now + ): + continue + + if _delete_workspace(sweep_client, user_id, table_name): + summary.expired_workspaces_deleted += 1 + else: + summary.errors += 1 + + logger.info( + "sweep_completed expired=%d business_skipped=%d inspected=%d errors=%d", + summary.expired_workspaces_deleted, + summary.business_workspaces_skipped, + summary.rows_inspected, + summary.errors, + ) + return summary + + +def main() -> None: + """CLI entry point. Mirrors HelpmateAI's `main()` in + `backend/maintenance.py`. The cron job (or a one-off operator + run) invokes this with `python -m backend.maintenance`; output + is JSON so structured-log pipelines can ingest it directly. + """ + summary = sweep_expired_workspaces() + print(json.dumps(summary.to_dict(), indent=2)) + + +if __name__ == "__main__": + main() + + +__all__ = [ + "SweepSummary", + "main", + "sweep_expired_workspaces", +] diff --git a/backend/model_routing.py b/backend/model_routing.py new file mode 100644 index 0000000..c1d6adc --- /dev/null +++ b/backend/model_routing.py @@ -0,0 +1,142 @@ +"""Tier-aware workflow model selection (Step 7a of tier-enforcement). + +Today every workflow agent (tailoring, review, resume_generation, +cover_letter) reads its model from `OPENAI_MODEL_ROUTING` keyed by the +agent's `task_name`. When a user opts into premium (premium=True on +/workspace/analyze) AND their tier supports it (Pro or Business), the +three "high-trust" agents — review, resume_generation, cover_letter — +should route to the premium model (gpt-5.5) instead of gpt-5.4. The +tailoring agent stays on mini regardless: the COGS analysis showed that +only the three review-grade agents benefit from the upgrade, and +keeping tailoring on mini is the difference between premium being +sustainable and not. + +This module exposes a single helper, `select_workflow_model`. It's +intentionally separate from `src/config.py::get_openai_model_for_task` +because: + + 1. Tier resolution is a backend concern (`backend.tiers`), not an + `src/` concern. Pulling `Tier` into `src/config.py` would invert + the dependency direction we've established (backend uses src, + never the other way around). + 2. The function returns `None` when no override is warranted, so the + caller can keep using the default per-task lookup without a + branch. None means "the standard model for this task is fine". + 3. The premium flag is the source of truth — never autodetect from + the user's session or sniff from a cookie. The route hands it to + the service, the service hands it here. + +Free tier passing premium=True is already blocked at the gate in +`/workspace/analyze` (Step 3 of the series), so this code never sees a +Free user with premium=True on the happy path. Defensive code below +still falls back gracefully (no upgrade) if it ever does, so a future +gate regression can't silently leak premium credits. +""" +from __future__ import annotations + +from typing import Optional + +from src.config import OPENAI_MODEL_ROUTING + +from backend.tiers import Tier + + +# Tasks that DO get the premium upgrade when (premium=True, tier in +# {pro, business}). Anything not in this set keeps its standard model +# even on a premium run. +# +# Tailoring is deliberately omitted — the COGS analysis pinned tailoring +# at gpt-5.4-mini regardless of plan. Skill summaries / resume builder +# / assistant turns are even further away from the workflow path; this +# helper only deals with the four orchestrator agents. +_PREMIUM_UPGRADE_TASKS: frozenset[str] = frozenset( + {"review", "resume_generation", "cover_letter"} +) + + +# Tiers that have a non-zero premium_applications cap. Hardcoding is +# acceptable here — the per-tier flag also lives in +# `TIER_CAPS[tier]["premium_applications"]`, but we don't want to take +# a hard dependency on the cap matrix to answer a yes/no question. +# If a future tier (e.g. "enterprise") wants premium, add it here AND +# bump its premium_applications cap in TIER_CAPS. +_PREMIUM_ELIGIBLE_TIERS: frozenset[Tier] = frozenset({"pro", "business"}) + + +def select_workflow_model( + *, + task: str, + tier: Tier, + premium: bool, +) -> Optional[str]: + """Decide whether to override the default model for this task. + + Returns the override model name (e.g. ``"gpt-5.5"``) when premium + routing applies, or ``None`` when the caller should use the + standard `OPENAI_MODEL_ROUTING[task]` lookup. ``None`` is the + sentinel for "no override" — callers branch on falsy / None and + fall through to their existing model-resolution path. + + Logic: + * `premium=False` → always None. Standard models everywhere. + * `tier` not in {pro, business} → None. The gate at + /workspace/analyze already rejected this combination with a + 429, so reaching here is defensive only. + * `task` not in {review, resume_generation, cover_letter} → + None. Tailoring + everything else stays on the standard tier. + * Otherwise → return the configured premium model (default + ``"gpt-5.5"`` via ``OPENAI_MODEL_PREMIUM``). + """ + if not premium: + return None + if tier not in _PREMIUM_ELIGIBLE_TIERS: + # Defensive: the gate should have caught this. Fall back to + # standard routing so a regression in the gate can't quietly + # bill the user for premium credits without delivering the + # upgraded model. The gate's the source of truth for what + # gets *charged*; this helper only decides what gets *served*. + return None + if task not in _PREMIUM_UPGRADE_TASKS: + return None + # `premium_high_trust` is a routing-table key, not a real task + # name passed to an agent. The lookup returns the configured + # premium model (default OPENAI_MODEL_PREMIUM = "gpt-5.5"). If the + # key is somehow missing (test isolation, malformed env), fall + # back to None rather than to a hardcoded model string — the + # default-routing path is then the safe choice. + return OPENAI_MODEL_ROUTING.get("premium_high_trust") + + +def build_workflow_model_overrides( + *, + tier: Tier, + premium: bool, +) -> dict[str, Optional[str]]: + """Pre-compute the model override for each workflow agent task. + + The orchestrator hands its agents the map at construction time so + each agent's `run_json_prompt` call can pass `model=...` directly + rather than re-deriving the tier on every prompt. Returns a dict + keyed by task name; values are either an override model string or + None (meaning "use the default for this task"). + """ + return { + "tailoring": select_workflow_model( + task="tailoring", tier=tier, premium=premium + ), + "review": select_workflow_model( + task="review", tier=tier, premium=premium + ), + "resume_generation": select_workflow_model( + task="resume_generation", tier=tier, premium=premium + ), + "cover_letter": select_workflow_model( + task="cover_letter", tier=tier, premium=premium + ), + } + + +__all__ = [ + "build_workflow_model_overrides", + "select_workflow_model", +] diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..fa6eb7e --- /dev/null +++ b/backend/models.py @@ -0,0 +1,150 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from src.schemas import JobPosting, JobResolutionResult, JobSearchQuery, JobSearchResult + + +class JobSearchRequestModel(BaseModel): + query: str = Field(min_length=1, max_length=200) + location: str = Field(default="", max_length=200) + source_filters: list[str] = Field(default_factory=list) + remote_only: bool = False + posted_within_days: int | None = Field(default=None, ge=1, le=30) + page_size: int = Field(default=20, ge=1, le=50) + # New filter dropdowns. work_modes is multi-select among + # ('remote', 'hybrid', 'onsite'). employment_types is multi- + # select among ('fulltime', 'parttime', 'contract', 'internship', + # 'temporary'). Empty list = no filter applied (all values pass). + work_modes: list[str] = Field(default_factory=list) + employment_types: list[str] = Field(default_factory=list) + # Single-select sort. 'relevance' (default — ts_rank then + # posted_at), 'newest' (posted_at DESC), 'oldest' (posted_at + # ASC), 'company_az' (alphabetical company). Unknown values + # coerce to 'relevance' downstream. + sort_by: str = Field(default="relevance", max_length=32) + + @field_validator("query", "location", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + @field_validator("query") + @classmethod + def _require_non_blank_query(cls, value: str): + if not value.strip(): + raise ValueError("query must not be blank") + return value + + @field_validator("source_filters", mode="before") + @classmethod + def _normalize_source_filters(cls, value): + if value is None: + return [] + if not isinstance(value, list): + raise ValueError("source_filters must be a list") + return [str(item).strip().lower() for item in value if str(item).strip()] + + @field_validator("work_modes", "employment_types", mode="before") + @classmethod + def _normalize_dropdown_list(cls, value): + # Same pattern as source_filters — accept a list of strings, + # lower-case + strip empties. Whitelisting against the + # allowed-values set happens in CachedJobsStore.search() + # so the schema layer stays decoupled from RPC vocabulary. + if value is None: + return [] + if not isinstance(value, list): + raise ValueError("must be a list of strings") + return [str(item).strip().lower() for item in value if str(item).strip()] + + @field_validator("sort_by", mode="before") + @classmethod + def _normalize_sort_by(cls, value): + return str(value or "relevance").strip().lower() or "relevance" + + def to_domain(self) -> JobSearchQuery: + return JobSearchQuery( + query=self.query, + location=self.location, + source_filters=self.source_filters, + remote_only=self.remote_only, + posted_within_days=self.posted_within_days, + page_size=self.page_size, + work_modes=self.work_modes, + employment_types=self.employment_types, + sort_by=self.sort_by, + ) + + +class JobPostingModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + source: str + title: str + company: str + location: str = "" + employment_type: str = "" + url: str = "" + summary: str = "" + description_text: str = "" + posted_at: str = "" + scraped_at: str = "" + metadata: dict = Field(default_factory=dict) + + @classmethod + def from_domain(cls, posting: JobPosting) -> "JobPostingModel": + return cls(**posting.__dict__) + + +class JobSearchResponseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + query: JobSearchRequestModel + results: list[JobPostingModel] = Field(default_factory=list) + total_results: int = 0 + source_status: dict[str, str] = Field(default_factory=dict) + + @classmethod + def from_domain(cls, result: JobSearchResult) -> "JobSearchResponseModel": + return cls( + query=JobSearchRequestModel.model_validate(result.query.__dict__), + results=[JobPostingModel.from_domain(item) for item in result.results], + total_results=result.total_results, + source_status=dict(result.source_status), + ) + + +class JobResolveRequestModel(BaseModel): + url: str = Field(min_length=1, max_length=500) + + @field_validator("url", mode="before") + @classmethod + def _strip_url(cls, value): + return str(value or "").strip() + + @field_validator("url") + @classmethod + def _require_non_blank_url(cls, value: str): + if not value.strip(): + raise ValueError("url must not be blank") + return value + + +class JobResolutionResponseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: str + status: str + job_posting: JobPostingModel | None = None + error_message: str = "" + source_details: dict[str, str] = Field(default_factory=dict) + + @classmethod + def from_domain(cls, result: JobResolutionResult) -> "JobResolutionResponseModel": + return cls( + source=result.source, + status=result.status, + job_posting=None if result.job_posting is None else JobPostingModel.from_domain(result.job_posting), + error_message=result.error_message, + source_details=dict(result.source_details), + ) diff --git a/backend/nightly_eval.py b/backend/nightly_eval.py new file mode 100644 index 0000000..ccc92c3 --- /dev/null +++ b/backend/nightly_eval.py @@ -0,0 +1,656 @@ +"""Nightly quality-evaluation CLI for AI Job Application Agent. + +Wraps the existing per-agent quality runners under ``tests/quality/`` in +a single batch script suitable for a VPS cron. Each runner already +scores itself against the same fixture pairs that the dev tier-3 suite +exercises; this script aggregates the headline metrics into one JSON +summary, compares them against an optional baseline, and exits non-zero +on any regression beyond ``--regression-threshold`` (default 5%). + +The point is "catch the night where a model upgrade silently drops +tailoring grounding from 0.93 to 0.78". The dev runners are designed +for human-eyeball inspection; this script is the unattended counter- +part — print a JSON line, exit 0/1, leave the rest to log-shipping. + +Usage: + python -m backend.nightly_eval # deterministic baseline + python -m backend.nightly_eval --include-llm # full LLM run (~$0.25 / night) + python -m backend.nightly_eval --include-llm --baseline X.json --output Y.json + python -m backend.nightly_eval --include-llm --regression-threshold 0.07 + +Exit codes: + 0 every runner passed AND no headline metric regressed + 1 at least one runner failed OR a metric regressed past the threshold + 2 fatal config error (no fixtures, etc.) + +The script is intentionally subprocess-free: it imports each runner's +helper functions directly so the OpenAI client / Supabase client stay +in-process and we don't pay extra cold-start cost per runner. Each +runner exposes the same ``score_*`` / ``FIXTURE_PAIRS`` surface that +the dev CLIs use, so we lean on those rather than parsing CLI output. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import time +import traceback +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Any, Callable, Optional + + +LOGGER = logging.getLogger("backend.nightly_eval") + + +# --------------------------------------------------------------------------- +# Data shapes +# --------------------------------------------------------------------------- + + +@dataclass +class RunnerOutcome: + """One runner's pass/fail snapshot. + + ``headline_metric`` is the single floating value we threshold against + the baseline (usually average overall score across fixtures). If the + runner threw or had no fixtures we leave it at ``None`` and mark + ``passed=False`` so a downstream observer can spot "didn't even + execute" cleanly from "executed but regressed". + """ + + name: str + passed: bool + duration_seconds: float + headline_metric: Optional[float] = None + metric_label: str = "" + fixture_count: int = 0 + error: str = "" + details: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class NightlySummary: + started_at: str + duration_seconds: float + include_llm: bool + regression_threshold: float + runners: list[dict[str, Any]] = field(default_factory=list) + failures: list[str] = field(default_factory=list) + regressions: list[dict[str, Any]] = field(default_factory=list) + baseline_path: str = "" + overall_pass: bool = False + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +# --------------------------------------------------------------------------- +# Individual runner adapters +# --------------------------------------------------------------------------- + + +def _now_iso() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _safe_average(values: list[float]) -> Optional[float]: + cleaned = [float(v) for v in values if v is not None] + if not cleaned: + return None + return round(sum(cleaned) / len(cleaned), 4) + + +def _run_tailoring(openai_service) -> RunnerOutcome: + """Programmatic wrapper around ``tests/quality/tailoring_quality_runner``. + + Scores the LLM mode when ``openai_service`` is available, otherwise + falls back to the deterministic mode (still useful for catching + fixture regressions even without a paid LLM run). + """ + from tests.quality.tailoring_quality_runner import ( + FIXTURE_PAIRS, + RESUMES_DIR, + JDS_DIR, + _build_inputs, + _run_deterministic, + _run_llm, + score_output, + ) + + started = time.perf_counter() + scores: list[float] = [] + fixtures_ok = 0 + for label, resume_filename, jd_filename in FIXTURE_PAIRS: + resume_path = RESUMES_DIR / resume_filename + jd_path = JDS_DIR / jd_filename + if not resume_path.exists() or not jd_path.exists(): + continue + candidate_profile, job_description, fit_analysis, tailored_draft = _build_inputs( + resume_path, jd_path + ) + if openai_service is not None: + output = _run_llm( + openai_service, + candidate_profile, + job_description, + fit_analysis, + tailored_draft, + ) + else: + output = _run_deterministic( + candidate_profile, job_description, fit_analysis, tailored_draft + ) + if output is None: + continue + result = score_output( + output, + candidate_profile.skills, + fit_analysis.matched_hard_skills, + fit_analysis.missing_hard_skills, + candidate_profile.resume_text, + ) + scores.append(result["overall"]) + fixtures_ok += 1 + + duration = round(time.perf_counter() - started, 3) + headline = _safe_average(scores) + return RunnerOutcome( + name="tailoring", + passed=bool(headline and headline >= 0.7) and fixtures_ok > 0, + duration_seconds=duration, + headline_metric=headline, + metric_label="average overall score", + fixture_count=fixtures_ok, + details={"per_fixture_overall": scores}, + ) + + +def _run_review(openai_service) -> RunnerOutcome: + """Programmatic wrapper around ``tests/quality/review_quality_runner``. + + The dev CLI exposes scenario scoring helpers; we reuse the clean + scenarios as a lightweight signal. Adversarial scenarios still run + in the dev workflow — here we want a stable headline that won't be + LLM-flaky in the cron. + """ + from tests.quality.review_quality_runner import ( + SCENARIOS, + RESUMES_DIR, + JDS_DIR, + ) + from src.agents.review_agent import ReviewAgent + from src.schemas import ResumeDocument + from src.services.fit_service import build_fit_analysis + from src.services.job_service import build_job_description_from_text + from src.services.profile_service import build_candidate_profile_from_resume_auto + from src.services.tailoring_service import build_tailored_resume_draft + + started = time.perf_counter() + approvals: list[bool] = [] + fixtures_ok = 0 + for scenario in SCENARIOS: + if scenario.get("mode") != "clean": + # Adversarial fixtures are expensive and LLM-dependent — keep + # them on the dev workflow. The cron wants a stable signal. + continue + resume_path = RESUMES_DIR / scenario["resume"] + jd_path = JDS_DIR / scenario["jd"] + if not resume_path.exists() or not jd_path.exists(): + continue + resume_text = resume_path.read_text(encoding="utf-8") + jd_text = jd_path.read_text(encoding="utf-8") + document = ResumeDocument(text=resume_text, filetype="TXT", source=str(resume_path)) + candidate_profile = build_candidate_profile_from_resume_auto(document) + job_description = build_job_description_from_text(jd_text) + fit_analysis = build_fit_analysis(candidate_profile, job_description) + tailored_draft = build_tailored_resume_draft( + candidate_profile, job_description, fit_analysis + ) + agent = ReviewAgent(openai_service=openai_service) + try: + review_output = agent.run( + candidate_profile, + job_description, + fit_analysis, + tailored_draft, + scenario["input_tailoring"], + ) + except Exception as exc: # noqa: BLE001 - one bad scenario shouldn't kill the run + LOGGER.warning( + "review_runner_scenario_failed", + extra={"scenario": scenario.get("label"), "error": str(exc)}, + ) + continue + approvals.append(bool(review_output.approved)) + fixtures_ok += 1 + + duration = round(time.perf_counter() - started, 3) + approval_rate = (sum(1 for v in approvals if v) / len(approvals)) if approvals else None + return RunnerOutcome( + name="review", + passed=bool(approval_rate and approval_rate >= 0.66) and fixtures_ok > 0, + duration_seconds=duration, + headline_metric=round(approval_rate, 4) if approval_rate is not None else None, + metric_label="clean-input approval rate", + fixture_count=fixtures_ok, + details={"approvals": approvals}, + ) + + +def _run_orchestrator_e2e(openai_service) -> RunnerOutcome: + """Run the end-to-end orchestrator scorecard. + + Mirrors ``orchestrator_e2e_runner.main`` but as a function so we + can capture the aggregate average without parsing stdout. + """ + from tests.quality.orchestrator_e2e_runner import ( + FIXTURE_PAIRS, + RESUMES_DIR, + JDS_DIR, + _run_fixture, + ) + + if openai_service is None: + # The E2E runner is meaningless without a real LLM — the four + # agents each have a deterministic fallback that the per-agent + # runners cover. Skip rather than emit a misleading metric. + return RunnerOutcome( + name="orchestrator_e2e", + passed=True, + duration_seconds=0.0, + headline_metric=None, + metric_label="skipped (requires --include-llm)", + fixture_count=0, + details={"skipped_reason": "no openai_service"}, + ) + + started = time.perf_counter() + overalls: list[float] = [] + fixtures_ok = 0 + errors: list[str] = [] + for label, resume_filename, jd_filename in FIXTURE_PAIRS: + resume_path = RESUMES_DIR / resume_filename + jd_path = JDS_DIR / jd_filename + if not resume_path.exists() or not jd_path.exists(): + continue + try: + result = _run_fixture( + label=label, + resume_path=resume_path, + jd_path=jd_path, + openai_service=openai_service, + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"{label}: {type(exc).__name__}: {exc}") + continue + overalls.append(float(result["overall"])) + fixtures_ok += 1 + + duration = round(time.perf_counter() - started, 3) + headline = _safe_average(overalls) + return RunnerOutcome( + name="orchestrator_e2e", + passed=bool(headline and headline >= 0.7) and fixtures_ok > 0 and not errors, + duration_seconds=duration, + headline_metric=headline, + metric_label="end-to-end average overall", + fixture_count=fixtures_ok, + error="; ".join(errors), + details={"per_fixture_overall": overalls, "errors": errors}, + ) + + +def _run_resume_parser(openai_service) -> RunnerOutcome: + """Deterministic resume-parser scorecard. + + Always-deterministic — the regex parser doesn't care whether + OpenAI is configured. A regression here usually means a fixture + edit changed canonical skills. + + The `openai_service` arg is accepted (and ignored) for signature + symmetry with the LLM-backed runners — the main loop calls all + five runners uniformly with the resolved service. + """ + del openai_service + from tests.quality.parser_quality_runner import ( + FIXTURES_DIR, + EXPECTED_DIR, + _run_deterministic, + score_profile, + ) + from src.schemas import ResumeDocument + + started = time.perf_counter() + scores: list[float] = [] + fixtures_ok = 0 + for fixture_path in sorted(FIXTURES_DIR.glob("*.txt")): + expected_path = EXPECTED_DIR / (fixture_path.stem + ".json") + if not expected_path.exists(): + continue + expected = json.loads(expected_path.read_text(encoding="utf-8")) + text = fixture_path.read_text(encoding="utf-8") + document = ResumeDocument(text=text, filetype="TXT", source="nightly") + profile = _run_deterministic(document) + result = score_profile(profile, expected) + scores.append(result["overall"]) + fixtures_ok += 1 + + duration = round(time.perf_counter() - started, 3) + headline = _safe_average(scores) + return RunnerOutcome( + name="resume_parser", + passed=bool(headline and headline >= 0.7) and fixtures_ok > 0, + duration_seconds=duration, + headline_metric=headline, + metric_label="parser average overall", + fixture_count=fixtures_ok, + details={"per_fixture_overall": scores}, + ) + + +def _run_jd_parser(openai_service) -> RunnerOutcome: + """Deterministic JD-parser scorecard. Same shape as resume_parser. + + `openai_service` accepted (and ignored) for runner-loop symmetry.""" + del openai_service + from tests.quality.jd_parser_quality_runner import ( + FIXTURES_DIR, + EXPECTED_DIR, + score_jd, + ) + from src.services.job_service import build_job_description_from_text + + started = time.perf_counter() + scores: list[float] = [] + fixtures_ok = 0 + for fixture_path in sorted(FIXTURES_DIR.glob("*.txt")): + expected_path = EXPECTED_DIR / (fixture_path.stem + ".json") + if not expected_path.exists(): + continue + expected = json.loads(expected_path.read_text(encoding="utf-8")) + text = fixture_path.read_text(encoding="utf-8") + jd = build_job_description_from_text(text) + result = score_jd(jd, expected) + scores.append(result["overall"]) + fixtures_ok += 1 + + duration = round(time.perf_counter() - started, 3) + headline = _safe_average(scores) + return RunnerOutcome( + name="jd_parser", + passed=bool(headline and headline >= 0.7) and fixtures_ok > 0, + duration_seconds=duration, + headline_metric=headline, + metric_label="jd parser average overall", + fixture_count=fixtures_ok, + details={"per_fixture_overall": scores}, + ) + + +# Registry order is intentional: cheap deterministic runners first so a +# fixture regression surfaces before we spend on LLM calls. The LLM +# runners (tailoring, review, orchestrator_e2e) only execute when +# ``--include-llm`` is passed AND OpenAIService is_available(). +_RUNNERS: list[tuple[str, Callable[[Any], RunnerOutcome], bool]] = [ + # (name, callable, requires_llm_for_meaningful_metric) + ("resume_parser", _run_resume_parser, False), + ("jd_parser", _run_jd_parser, False), + ("tailoring", _run_tailoring, True), + ("review", _run_review, True), + ("orchestrator_e2e", _run_orchestrator_e2e, True), +] + + +# --------------------------------------------------------------------------- +# Baseline comparison +# --------------------------------------------------------------------------- + + +def _load_baseline(path: Optional[Path]) -> dict[str, float]: + """Read a previously-emitted nightly summary and return a name → metric map. + + Returns an empty dict when the baseline file is missing or + unparseable. The caller treats "no baseline" as "no regression + check" rather than failing — the first night runs without + history. + """ + if not path: + return {} + if not path.exists(): + LOGGER.warning("nightly_eval_baseline_missing", extra={"path": str(path)}) + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + LOGGER.warning( + "nightly_eval_baseline_unreadable", + extra={"path": str(path), "error": str(exc)}, + ) + return {} + baseline: dict[str, float] = {} + for runner in payload.get("runners") or []: + name = runner.get("name") + metric = runner.get("headline_metric") + if name and isinstance(metric, (int, float)): + baseline[name] = float(metric) + return baseline + + +def _detect_regressions( + runners: list[RunnerOutcome], + baseline: dict[str, float], + threshold: float, +) -> list[dict[str, Any]]: + """Return a list of {name, baseline, current, delta} for any runner + whose headline metric dropped by more than ``threshold`` (absolute). + + Improvements are not regressions; missing baselines are not + regressions. A baseline of 0.93 vs a current of 0.80 with + threshold=0.05 is a regression (delta=-0.13). A baseline of 0.93 + vs current of 0.91 with threshold=0.05 is NOT a regression + (delta=-0.02). + """ + regressions: list[dict[str, Any]] = [] + for outcome in runners: + if outcome.headline_metric is None: + continue + previous = baseline.get(outcome.name) + if previous is None: + continue + delta = outcome.headline_metric - previous + if delta < -abs(threshold): + regressions.append( + { + "name": outcome.name, + "metric_label": outcome.metric_label, + "baseline": round(previous, 4), + "current": round(outcome.headline_metric, 4), + "delta": round(delta, 4), + "threshold": threshold, + } + ) + return regressions + + +# --------------------------------------------------------------------------- +# Main orchestrator +# --------------------------------------------------------------------------- + + +def _resolve_openai_service(include_llm: bool): + if not include_llm: + return None + try: + from src.openai_service import OpenAIService + except Exception as exc: # pragma: no cover - import guard + LOGGER.error("nightly_eval_openai_import_failed", extra={"error": str(exc)}) + return None + service = OpenAIService() + if not service.is_available(): + LOGGER.warning( + "nightly_eval_openai_unavailable", + extra={"reason": "OPENAI_API_KEY missing or invalid"}, + ) + return None + return service + + +def run_nightly_eval( + *, + include_llm: bool = False, + baseline_path: Optional[Path] = None, + output_path: Optional[Path] = None, + regression_threshold: float = 0.05, + runners: Optional[list[str]] = None, +) -> NightlySummary: + """Execute every registered runner and emit a JSON summary. + + Pure-Python entry point so the smoke tests (and any future cron- + HTTP-trigger) can exercise this without going through ``__main__``. + """ + started_at = _now_iso() + overall_start = time.perf_counter() + + openai_service = _resolve_openai_service(include_llm) + + selected_runners = runners or [name for name, _, _ in _RUNNERS] + outcomes: list[RunnerOutcome] = [] + for name, runner_fn, _requires_llm in _RUNNERS: + if name not in selected_runners: + continue + try: + outcome = runner_fn(openai_service) + except Exception as exc: # noqa: BLE001 - one bad runner shouldn't kill the report + LOGGER.exception("nightly_eval_runner_crashed", extra={"runner": name}) + outcome = RunnerOutcome( + name=name, + passed=False, + duration_seconds=0.0, + error=f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}", + ) + outcomes.append(outcome) + LOGGER.info( + "nightly_eval_runner_finished", + extra={ + "runner": name, + "passed": outcome.passed, + "metric": outcome.headline_metric, + "metric_label": outcome.metric_label, + "duration_seconds": outcome.duration_seconds, + "fixture_count": outcome.fixture_count, + }, + ) + + baseline = _load_baseline(baseline_path) + regressions = _detect_regressions(outcomes, baseline, regression_threshold) + failures = [o.name for o in outcomes if not o.passed] + + summary = NightlySummary( + started_at=started_at, + duration_seconds=round(time.perf_counter() - overall_start, 3), + include_llm=include_llm and openai_service is not None, + regression_threshold=regression_threshold, + runners=[asdict(o) for o in outcomes], + failures=failures, + regressions=regressions, + baseline_path=str(baseline_path) if baseline_path else "", + overall_pass=not failures and not regressions, + ) + + if output_path: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(summary.to_dict(), indent=2), encoding="utf-8" + ) + + return summary + + +def _configure_logging(verbosity: int) -> None: + level = logging.WARNING + if verbosity == 1: + level = logging.INFO + elif verbosity >= 2: + level = logging.DEBUG + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--include-llm", + action="store_true", + help="Run the LLM-dependent runners (tailoring, review, e2e). Requires OPENAI_API_KEY.", + ) + parser.add_argument( + "--baseline", + type=Path, + default=None, + help="Previous nightly summary JSON to compare current metrics against.", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="Path to write the JSON summary (also goes to stdout).", + ) + parser.add_argument( + "--regression-threshold", + type=float, + default=0.05, + help="Maximum allowed drop in any headline metric (absolute, e.g. 0.05 = 5 points).", + ) + parser.add_argument( + "--runner", + action="append", + dest="runners", + default=None, + help="Limit to a specific runner. Pass multiple times to add more. Default: all.", + ) + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase log verbosity (-v INFO, -vv DEBUG).", + ) + args = parser.parse_args(argv) + _configure_logging(args.verbose) + + summary = run_nightly_eval( + include_llm=args.include_llm, + baseline_path=args.baseline, + output_path=args.output, + regression_threshold=args.regression_threshold, + runners=args.runners, + ) + + # Stdout payload is the canonical record for log shippers. Keep it a + # single JSON object (no newlines inside) so ``journalctl`` / + # ``docker logs`` / log shippers downstream can grep one line per + # nightly run. + print(json.dumps(summary.to_dict())) + + if summary.failures: + LOGGER.warning( + "nightly_eval_failed", + extra={"failures": summary.failures, "regressions": summary.regressions}, + ) + elif summary.regressions: + LOGGER.warning("nightly_eval_regressed", extra={"regressions": summary.regressions}) + + return 0 if summary.overall_pass else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/quota.py b/backend/quota.py new file mode 100644 index 0000000..08d86f4 --- /dev/null +++ b/backend/quota.py @@ -0,0 +1,558 @@ +"""Atomic check-and-increment helper for tier quota counters. + +Step 2 of the tier-enforcement series. The single entry point is +`check_and_increment(counter_name, user_id, tier, *, lifetime=False)`, +which: + + 1. Resolves the cap for (tier, counter_name) via `TIER_CAPS`. + 2. Short-circuits when the cap equals `UNLIMITED` -- never touches + the database, returns a sentinel `QuotaResult` so the call site + can still log the result uniformly. + 3. Calls the Supabase `increment_aijobagent_counter` RPC, which + atomically UPSERTs and either returns the new count or raises a + SQLSTATE 'P0001' with detail `aijobagent_quota_exceeded`. Atomic + means two concurrent workspace runs from the same user produce + N+1 and N+2 -- never both N+1. + 4. On the 'P0001' branch, raises `QuotaExceededError` carrying the + structured fields the FastAPI handler needs to build the 429. + +The `lifetime` kwarg flips the period_key the row is written under +("lifetime" vs current "YYYY-MM"). Steps 4-8 expose this kwarg to the +resume-builder and persistent-count counters; step 3 only uses the +default monthly form. + +`refund(counter_name, user_id, tier, *, lifetime=False)` decrements a +counter by 1, flooring at zero. Use it from the workflow-failure path +so a transient orchestrator error doesn't burn a user's quota credit. +The refund call shares the same RPC (with delta=-1), so the audit +trail in `updated_at` still reflects the change. + +Backend selection: + * The Supabase service-role client is required for the RPC because + the RPC takes user_id as a parameter rather than reading + auth.uid(). Granting EXECUTE to authenticated would let any + signed-in user burn anybody else's quota. + * When Supabase isn't configured (local dev, CI without secrets), + we degrade to an in-memory store keyed by (user_id, period, + counter) so unit tests and offline workflows still go through the + same code path. The in-memory store is process-local and not safe + under concurrent workers -- production must run with Supabase. +""" +from __future__ import annotations + +import logging +import os +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from backend.tiers import TIER_CAPS, UNLIMITED, Tier +from src.config import ( + SUPABASE_SERVICE_ROLE_KEY, + SUPABASE_URL, +) +from src.errors import QuotaExceededError + + +# Upgrade-page URL surfaced in 429 payloads and the /workspace/quota +# response so the frontend's upgrade-nudge CTA points somewhere real. +# Reads from AIJOBAGENT_UPGRADE_URL at import time so prod / staging / +# dev can each point at the right pricing page. The default mirrors +# the production landing site -- update if the marketing URL moves. +UPGRADE_URL = os.getenv( + "AIJOBAGENT_UPGRADE_URL", + "https://ai-job-agent.example.com/pricing", +).strip() + + +try: # supabase is an optional dep in some test paths + from supabase import create_client as _create_supabase_client # type: ignore +except Exception: # pragma: no cover - defensive import + _create_supabase_client = None # type: ignore + + +logger = logging.getLogger(__name__) + + +# Period-key literals. The Supabase composite PK is keyed by period_key +# as a free-form text column -- the application supplies whichever form +# is right for the counter. Keep these centralized so a typo in one +# call site can't desync the partition. +LIFETIME_PERIOD_KEY = "lifetime" + + +@dataclass(frozen=True) +class QuotaResult: + """Snapshot returned on a successful check_and_increment call. + + `remaining` is computed against the post-increment count: a user + with a Free cap of 3 and `count == 3` has `remaining == 0`, which + the next call will reject. For `UNLIMITED` counters `cap` and + `remaining` are both `UNLIMITED` so the caller can short-circuit + any UI nudge logic with `result.cap == UNLIMITED`. + """ + + count: int + cap: int + remaining: int + + +def current_period_key(now: Optional[datetime] = None) -> str: + """Return the YYYY-MM key for the calendar month in UTC. + + The Supabase row partitions naturally by this key -- no scheduled + "reset" job, the next month's first increment lands in a new row + with `count = 1`. `now` exists for tests; defaults to real UTC + now. + """ + moment = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + return f"{moment.year:04d}-{moment.month:02d}" + + +def _period_key_for(*, lifetime: bool, now: Optional[datetime] = None) -> str: + return LIFETIME_PERIOD_KEY if lifetime else current_period_key(now) + + +def _cap_for(tier: Tier, counter_name: str) -> int: + """Look up a per-tier cap or raise KeyError on a typo. + + A missing counter is a bug in the call site (or the TIER_CAPS + matrix). We deliberately let KeyError propagate rather than + defaulting to UNLIMITED -- a silent "you're unlimited!" failure + mode is the worst possible behavior for a billing gate. + """ + return TIER_CAPS[tier][counter_name] + + +def _build_quota_exceeded_error( + *, + counter_name: str, + current: int, + cap: int, + tier: Tier, + period_key: str, +) -> QuotaExceededError: + if counter_name == "premium_applications" and cap == 0: + message = ( + "Premium applications are a Pro+ feature. Upgrade to run " + "premium tailoring for this job." + ) + else: + message = ( + "You have reached the limit for this action on your current " + "plan. Upgrade to continue or wait for the period to reset." + ) + return QuotaExceededError( + message, + counter=counter_name, + current=current, + cap=cap, + reset_period=period_key, + tier=tier, + ) + + +# ─── Backend abstraction ──────────────────────────────────────────────── + + +class _InMemoryQuotaBackend: + """Process-local fallback used when Supabase isn't configured. + + Mirrors the SQL function's semantics: atomic increment, cap check + on positive delta, UNLIMITED short-circuit (the caller already + handled this case but the backend defends too), refund flooring at + zero. Thread-safe via a single lock -- concurrency in tests is + handled correctly; production must run with the Supabase backend. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._store: dict[tuple[str, str, str], int] = {} + + def reset(self) -> None: + with self._lock: + self._store.clear() + + def increment( + self, + *, + user_id: str, + period_key: str, + counter_name: str, + cap: int, + delta: int, + ) -> int: + key = (user_id, period_key, counter_name) + with self._lock: + current = self._store.get(key, 0) + if delta == 0: + return current + if cap >= 0 and delta > 0 and current + delta > cap: + # SQLSTATE P0001 in the SQL function; translate to the + # same Python signal so the caller's except branch + # handles both backends uniformly. + raise _QuotaExceededAtBackend( + counter_name=counter_name, + current=current, + cap=cap, + ) + new = max(current + delta, 0) + self._store[key] = new + return new + + def read( + self, + *, + user_id: str, + period_key: str, + counter_name: str, + ) -> int: + """Pure read of the current counter value. Returns 0 when no + row exists -- the period hasn't been touched yet. No locking + needed for a single dict lookup; the value is a momentary + snapshot, not transactional with respect to concurrent + increments (which is fine for /workspace/quota's + informational read).""" + return int(self._store.get((user_id, period_key, counter_name), 0)) + + +class _QuotaExceededAtBackend(Exception): + """Internal signal raised by either backend when the SQL/in-memory + increment would breach the cap. Translated to `QuotaExceededError` + in the public entry points -- the public error carries `reset_period` + and `tier`, which the backend layer shouldn't need to know about. + """ + + def __init__(self, *, counter_name: str, current: int, cap: int) -> None: + self.counter_name = counter_name + self.current = current + self.cap = cap + + +class _SupabaseQuotaBackend: + """Service-role-backed quota store calling the atomic RPC. + + Uses lazy client initialization so importing this module without + SUPABASE_URL / SERVICE_ROLE_KEY does not crash -- the in-memory + fallback handles that path. is_configured() decides which backend + `check_and_increment` actually dispatches to. + """ + + def __init__( + self, + *, + supabase_url: str = SUPABASE_URL, + service_role_key: str = SUPABASE_SERVICE_ROLE_KEY, + ) -> None: + self._url = supabase_url + self._key = service_role_key + self._client = None + + def is_configured(self) -> bool: + return bool(self._url and self._key and _create_supabase_client is not None) + + def _require_client(self): + if self._client is None: + self._client = _create_supabase_client(self._url, self._key) + return self._client + + def increment( + self, + *, + user_id: str, + period_key: str, + counter_name: str, + cap: int, + delta: int, + ) -> int: + client = self._require_client() + try: + result = client.rpc( + "increment_aijobagent_counter", + { + "p_user_id": user_id, + "p_period_key": period_key, + "p_counter_name": counter_name, + "p_cap": cap, + "p_delta": delta, + }, + ).execute() + except Exception as exc: # noqa: BLE001 - boundary translation + # supabase-py wraps PostgREST errors in APIError; the + # message contains the SQL DETAIL we wrote in the function. + message = str(exc) + if "aijobagent_quota_exceeded" in message: + # The RPC ran the SELECT-for-update before raising, so + # the message detail carries the current count we need + # to surface in the 429 payload. Best-effort parse; + # fall back to a generic "at-or-above-cap" assumption. + current = _parse_current_from_rpc_error(message, fallback=cap) + raise _QuotaExceededAtBackend( + counter_name=counter_name, + current=current, + cap=cap, + ) from exc + raise + + data = getattr(result, "data", None) + if isinstance(data, list): + return int(data[0]) if data else 0 + if data is None: + return 0 + return int(data) + + def read( + self, + *, + user_id: str, + period_key: str, + counter_name: str, + ) -> int: + """Best-effort read of the current counter value from the + Supabase row, used by /workspace/quota. Returns 0 when no row + exists or when the Supabase round-trip fails -- the read is + purely informational (drives the UI's used/limit indicator), + so swallowing transient errors is the right behavior; the + next increment still goes through the atomic RPC.""" + try: + client = self._require_client() + response = ( + client.table("aijobagent_quota_counters") + .select("count") + .eq("user_id", user_id) + .eq("period_key", period_key) + .eq("counter_name", counter_name) + .limit(1) + .execute() + ) + except Exception: # noqa: BLE001 - read is best-effort + logger.exception( + "quota_read_failed counter=%s user_id=%s period_key=%s", + counter_name, + user_id, + period_key, + ) + return 0 + data = getattr(response, "data", None) or [] + if not data: + return 0 + first = data[0] if isinstance(data, list) else data + if not isinstance(first, dict): + return 0 + try: + return int(first.get("count", 0) or 0) + except (TypeError, ValueError): + return 0 + + +def _parse_current_from_rpc_error(message: str, *, fallback: int) -> int: + """Pull `current=` out of the SQL DETAIL string. + + The SQL function raises with detail + `counter= cap= current=`. The supabase-py wrapper + surfaces this as part of the APIError message. We do a small, + forgiving parse here so the 429 payload still carries an accurate + `current` even when supabase-py upgrades its error shape. + """ + marker = "current=" + idx = message.find(marker) + if idx < 0: + return fallback + tail = message[idx + len(marker) :] + digits = "" + for ch in tail: + if ch.isdigit(): + digits += ch + continue + break + if not digits: + return fallback + return int(digits) + + +# Module-level singletons. Tests reach in via `reset_in_memory_backend` +# or by monkeypatching `_BACKEND` directly. Production resolves to the +# Supabase backend automatically once the env vars are set. +_IN_MEMORY_BACKEND = _InMemoryQuotaBackend() +_SUPABASE_BACKEND = _SupabaseQuotaBackend() + + +def _select_backend(): + if _SUPABASE_BACKEND.is_configured(): + return _SUPABASE_BACKEND + return _IN_MEMORY_BACKEND + + +def reset_in_memory_backend() -> None: + """Wipe the process-local fallback store. Test-only -- production + runs through Supabase and has no equivalent. + """ + _IN_MEMORY_BACKEND.reset() + + +# ─── Public API ───────────────────────────────────────────────────────── + + +def check_and_increment( + counter_name: str, + user_id: str, + tier: Tier, + *, + lifetime: bool = False, + now: Optional[datetime] = None, +) -> QuotaResult: + """Atomically increment the counter or raise QuotaExceededError. + + `counter_name` must be a key in TIER_CAPS[tier]; an unknown counter + is a programming bug and surfaces as KeyError. `lifetime=True` + writes to the "lifetime" period_key (used for Free-tier + resume_builder_sessions in step 4); the default uses the current + YYYY-MM partition. + + Returns: + QuotaResult(count, cap, remaining) on success. For UNLIMITED + counters returns `QuotaResult(count=0, cap=UNLIMITED, remaining=UNLIMITED)` + without touching the database -- the count is not tracked. + + Raises: + QuotaExceededError if the increment would breach the tier cap. + Underlying network / Supabase errors propagate as the original + exception so the caller can decide how to fail open or closed. + """ + cap = _cap_for(tier, counter_name) + period_key = _period_key_for(lifetime=lifetime, now=now) + + if cap == UNLIMITED: + # Don't write a row -- there's no useful number to track for an + # unlimited counter, and not writing keeps the table compact. + return QuotaResult(count=0, cap=UNLIMITED, remaining=UNLIMITED) + + backend = _select_backend() + try: + new_count = backend.increment( + user_id=user_id, + period_key=period_key, + counter_name=counter_name, + cap=cap, + delta=1, + ) + except _QuotaExceededAtBackend as exc: + raise _build_quota_exceeded_error( + counter_name=exc.counter_name, + current=exc.current, + cap=exc.cap, + tier=tier, + period_key=period_key, + ) from None + + return QuotaResult( + count=new_count, + cap=cap, + remaining=max(cap - new_count, 0), + ) + + +def read_counter( + counter_name: str, + user_id: str, + tier: Tier, + *, + lifetime: bool = False, + now: Optional[datetime] = None, +) -> int: + """Read the current counter value WITHOUT incrementing it. + + Used by /workspace/quota (step 7b) to populate the per-user quota + snapshot the frontend renders. Returns 0 when: + * the counter row hasn't been written this period yet, OR + * the tier cap is UNLIMITED (the helper never writes a row for + unlimited counters -- there's no useful number to track). + + No exception path: read failures from the Supabase backend log and + return 0 so a transient cache miss doesn't break the /workspace/quota + UI. The next `check_and_increment` call still goes through the + atomic RPC, so a wrong-by-one informational read can't lead to a + cap breach. + """ + cap = _cap_for(tier, counter_name) + if cap == UNLIMITED: + # No row is ever written for unlimited counters (see + # check_and_increment); the helper short-circuits to 0 to + # keep the UI's used/limit copy stable rather than throwing + # on a "no such row" round-trip. + return 0 + + period_key = _period_key_for(lifetime=lifetime, now=now) + backend = _select_backend() + return backend.read( + user_id=user_id, + period_key=period_key, + counter_name=counter_name, + ) + + +def refund( + counter_name: str, + user_id: str, + tier: Tier, + *, + lifetime: bool = False, + now: Optional[datetime] = None, +) -> Optional[int]: + """Decrement the counter by 1, flooring at zero. + + Use this from the workflow-failure path so a transient orchestrator + failure doesn't burn a user's quota credit. Refunds are best-effort: + if the decrement fails (Supabase outage, etc.) we log and swallow + -- the user's account already absorbed the increment, and bubbling + the failure here would mask the original orchestrator exception + that the caller is trying to re-raise. + + Returns the new count on success, or None when no refund was + necessary (UNLIMITED counter -- nothing was incremented to begin + with). + """ + cap = _cap_for(tier, counter_name) + if cap == UNLIMITED: + return None + + period_key = _period_key_for(lifetime=lifetime, now=now) + backend = _select_backend() + try: + return backend.increment( + user_id=user_id, + period_key=period_key, + counter_name=counter_name, + cap=cap, + delta=-1, + ) + except _QuotaExceededAtBackend: + # Refund (negative delta) cannot trigger the cap check in the + # SQL function. If we get here it's a different error path -- + # log and swallow so the caller can re-raise the original + # workflow exception. + logger.warning( + "quota_refund_cap_branch_unexpectedly_hit", + extra={"counter": counter_name, "user_id": user_id}, + ) + return None + except Exception: # noqa: BLE001 - refund is best-effort + logger.exception( + "quota_refund_failed counter=%s user_id=%s", + counter_name, + user_id, + ) + return None + + +__all__ = [ + "LIFETIME_PERIOD_KEY", + "QuotaResult", + "UPGRADE_URL", + "check_and_increment", + "current_period_key", + "read_counter", + "refund", + "reset_in_memory_backend", +] diff --git a/backend/rate_limit.py b/backend/rate_limit.py new file mode 100644 index 0000000..6c72afa --- /dev/null +++ b/backend/rate_limit.py @@ -0,0 +1,145 @@ +"""Rate limiting for FastAPI endpoints. + +Bucketing strategy: +- Authenticated requests bucket by Supabase user-id (decoded locally + from the access-token JWT, no signature verification; see + _extract_user_id_from_jwt for why this is safe). +- Anonymous requests fall back to client IP. +- Both bucket key forms are namespaced so a forged JWT 'sub' cannot + collide with an IP-bucketed anonymous user. + +Limits are exposed as named constants so each route picks a tier +explicitly and the budgets are easy to audit in one place. + +A RATE_LIMIT_OVERRIDE env var (e.g. "2/minute") can be set at process +startup to globally override the budgets; used by the test suite to +exercise the limiter without firing dozens of real requests. +""" +from __future__ import annotations + +import base64 +import json +import logging +import os +from typing import Optional + +from fastapi import Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from backend.services.auth_cookies import ACCESS_TOKEN_COOKIE +from src.logging_utils import get_logger, log_event + + +LOGGER = get_logger(__name__) + + +def _extract_user_id_from_jwt(token: str) -> Optional[str]: + """Decode a Supabase access-token JWT and return the 'sub' claim. + + This deliberately does NOT verify the signature. Two reasons that's + safe for rate-limit bucketing only: + + 1. The endpoint's auth dependency (resolve_authenticated_context) + still verifies the token via Supabase before performing any + privileged action. A forged token cannot do real work. + 2. A forger of someone else's 'sub' would burn through that user's + rate quota only: a denial-of-service against one account, not + privilege escalation. To bound that risk further, anonymous and + authenticated buckets share a namespace prefix below so an + attacker still has to forge a valid-shape JWT to even attempt it. + + Never use this function for authorization decisions. + """ + if not token: + return None + parts = token.split(".") + if len(parts) != 3: + return None + payload_b64 = parts[1] + # Pad for base64url decoding + padding = "=" * (-len(payload_b64) % 4) + try: + decoded = base64.urlsafe_b64decode(payload_b64 + padding) + claims = json.loads(decoded) + except (ValueError, json.JSONDecodeError): + return None + sub = claims.get("sub") + if not isinstance(sub, str) or not sub.strip(): + return None + return sub.strip() + + +def resolve_rate_limit_key(request: Request) -> str: + """Stable key for slowapi to bucket by. + + Returns 'user:' for authenticated requests, 'ip:' + otherwise. The namespacing prevents a forged JWT bucket from + sharing state with an IP bucket. + """ + access_token = ( + request.cookies.get(ACCESS_TOKEN_COOKIE, "").strip() + or request.headers.get("X-Auth-Access-Token", "").strip() + ) + user_id = _extract_user_id_from_jwt(access_token) if access_token else None + if user_id: + return f"user:{user_id}" + return f"ip:{get_remote_address(request)}" + + +# Allow the test suite (or an operator on a hot fix) to lower limits +# without code changes. Format: "/" e.g. "2/minute". +_LIMIT_OVERRIDE = os.getenv("RATE_LIMIT_OVERRIDE", "").strip() + + +def _budget(default: str) -> str: + return _LIMIT_OVERRIDE or default + + +# Tier 1: heavy LLM workflows (full agent pipeline or comparable). +LIMIT_HEAVY = _budget("10/minute") +# Tier 2: single LLM call or external job-board fan-out. +LIMIT_LLM = _budget("30/minute") +# Tier 3: file parsing, artifact rendering: CPU-bound but cheap. +LIMIT_PARSE = _budget("60/minute") + + +limiter = Limiter( + key_func=resolve_rate_limit_key, + # Headers are injected by SlowAPIMiddleware on the response object + # (see backend/app.py); the decorator-level injector requires a + # `response: Response` parameter on every route, which we avoid. + headers_enabled=False, +) + + +def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): + """Return a clean JSON 429 with a Retry-After header. + + slowapi's default handler returns plain text; we want the same + {"detail": "..."} shape as the rest of the API so the frontend + error path is uniform. + """ + log_event( + LOGGER, + logging.WARNING, + "rate_limit_exceeded", + "Request exceeded rate limit.", + path=request.url.path, + method=request.method, + bucket_key=resolve_rate_limit_key(request), + limit=str(exc.detail) if exc.detail else "", + ) + response = JSONResponse( + status_code=429, + content={ + "detail": "Too many requests. Please slow down and try again shortly.", + "limit": str(exc.detail) if exc.detail else None, + }, + ) + # slowapi attaches a Retry-After header via the SlowAPIMiddleware, + # but we set one here too for handlers that don't run middleware. + response.headers["Retry-After"] = "60" + return response diff --git a/backend/request_auth.py b/backend/request_auth.py new file mode 100644 index 0000000..cc86e39 --- /dev/null +++ b/backend/request_auth.py @@ -0,0 +1,34 @@ +from typing import Optional + +from fastapi import Cookie, Header + +from backend.services.auth_cookies import ( + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, +) + + +def get_optional_auth_tokens( + # Primary path: HttpOnly cookies set on /auth/google/exchange and + # /auth/session/restore. Frontend never sees them; the browser + # attaches them automatically on every request. + access_cookie: Optional[str] = Cookie(default=None, alias=ACCESS_TOKEN_COOKIE), + refresh_cookie: Optional[str] = Cookie(default=None, alias=REFRESH_TOKEN_COOKIE), + # Header fallback retained for the deploy window so any tab that + # was open with localStorage tokens at the moment of cutover still + # works until its next sign-in. Safe to remove once the rollout has + # stabilized for a few days. + x_auth_access_token: Optional[str] = Header(default=None, alias="X-Auth-Access-Token"), + x_auth_refresh_token: Optional[str] = Header(default=None, alias="X-Auth-Refresh-Token"), +): + access_token = ( + str(access_cookie or "").strip() + or str(x_auth_access_token or "").strip() + or None + ) + refresh_token = ( + str(refresh_cookie or "").strip() + or str(x_auth_refresh_token or "").strip() + or None + ) + return access_token, refresh_token diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..4b1ffb1 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +"""API route definitions for the backend service.""" diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..3f57aad --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException, Response + +from backend.auth_models import ( + GoogleSignInExchangeRequestModel, + GoogleSignInStartRequestModel, + WorkspaceHandoffExchangeRequestModel, + WorkspaceHandoffStartRequestModel, +) +from backend.request_auth import get_optional_auth_tokens +from backend.services.auth_cookies import ( + clear_auth_cookies, + set_auth_cookies, +) +from backend.services.auth_handoff_service import ( + exchange_workspace_handoff, + start_workspace_handoff, +) +from backend.services.auth_session_service import ( + exchange_google_code, + restore_authenticated_session, + sign_out_authenticated_session, + start_google_sign_in, +) +from src.errors import AppError + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _raise_http_error(error: AppError): + raise HTTPException(status_code=400, detail=error.user_message) + + +def _scrub_session_tokens(payload: dict) -> dict: + """Strip raw access/refresh tokens out of the JSON body. + + Tokens live in HttpOnly cookies now; the frontend has no business + reading them, so we don't ship them to the browser. We keep the + ``session`` key with a non-token shape so the existing TS type + can still pattern-match against authenticated responses. + """ + if not isinstance(payload, dict): + return payload + if payload.get("session"): + payload["session"] = {"authenticated": True} + return payload + + +def _apply_session_cookies(response: Response, payload: dict) -> None: + session = payload.get("session") if isinstance(payload, dict) else None + if not isinstance(session, dict): + return + access_token = str(session.get("access_token") or "").strip() + refresh_token = str(session.get("refresh_token") or "").strip() + if access_token and refresh_token: + set_auth_cookies(response, access_token, refresh_token) + + +@router.post("/google/start") +def start_google_sign_in_route(payload: GoogleSignInStartRequestModel): + try: + return start_google_sign_in(redirect_url=payload.redirect_url) + except AppError as error: + _raise_http_error(error) + + +@router.post("/google/exchange") +def exchange_google_code_route( + payload: GoogleSignInExchangeRequestModel, + response: Response, +): + try: + result = exchange_google_code( + auth_code=payload.auth_code, + auth_flow=payload.auth_flow, + redirect_url=payload.redirect_url, + ) + _apply_session_cookies(response, result) + return _scrub_session_tokens(result) + except AppError as error: + _raise_http_error(error) + + +@router.post("/session/restore") +def restore_session_route( + response: Response, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + result = restore_authenticated_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + # Re-issue the cookies on each restore. Even if the underlying + # tokens didn't rotate, this slides the browser-side expiry so a + # daily-active user effectively stays signed in indefinitely. + _apply_session_cookies(response, result) + return _scrub_session_tokens(result) + except AppError as error: + _raise_http_error(error) + + +@router.post("/workspace-handoff/start") +def start_workspace_handoff_route( + payload: WorkspaceHandoffStartRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return start_workspace_handoff( + access_token=access_token or "", + refresh_token=refresh_token or "", + target_url=payload.target_url, + ) + except AppError as error: + _raise_http_error(error) + + +@router.post("/workspace-handoff/exchange") +def exchange_workspace_handoff_route( + payload: WorkspaceHandoffExchangeRequestModel, + response: Response, +): + try: + result = exchange_workspace_handoff( + handoff_token=payload.handoff_token, + ) + _apply_session_cookies(response, result) + return _scrub_session_tokens(result) + except AppError as error: + _raise_http_error(error) + + +@router.post("/session/sign-out") +def sign_out_route( + response: Response, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + result = sign_out_authenticated_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + clear_auth_cookies(response) + return result + except AppError as error: + # Always clear cookies on sign-out attempts, even if upstream + # revocation fails, we don't want stale cookies lingering on + # the client. + clear_auth_cookies(response) + _raise_http_error(error) diff --git a/backend/routers/billing.py b/backend/routers/billing.py new file mode 100644 index 0000000..31686a6 --- /dev/null +++ b/backend/routers/billing.py @@ -0,0 +1,246 @@ +"""Billing routes: LS webhook + customer portal. + +Two endpoints: + + * POST /webhooks/lemonsqueezy + LS-delivered subscription event. Verifies the HMAC signature + on the raw body, dispatches to + `backend.webhooks.lemonsqueezy.process_webhook`. Returns 200 + on every signature-valid call (even when the event was + skipped) so LS doesn't retry. 401 on signature mismatch, + 503 when the webhook secret env var isn't configured. + + * POST /billing/portal + Authenticated. Calls the LS customer portal API to mint a + one-time URL the frontend can window.location to. Returns + 503 when the LS_API_KEY env var isn't configured -- lets the + integration ship to main without LS being live yet. + +The webhook endpoint is intentionally hosted at /webhooks/lemonsqueezy +(no /api prefix on the path proper, but the app's settings.api_prefix +still applies in app.include_router) so the LS dashboard registration +URL is short. Tests for the LS-side rendering live in +tests/backend/test_lemonsqueezy_webhook.py. +""" +from __future__ import annotations + +import logging +import os +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request + +from backend.request_auth import get_optional_auth_tokens +from backend.services.auth_session_service import resolve_authenticated_context +from backend.webhooks.lemonsqueezy import ( + InvalidWebhookSignature, + WebhookConfigError, + process_webhook, +) +from src.errors import AppError + + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["billing"]) + + +# LS portal API is rate-limited per-customer; we only call it on +# explicit user clicks (Manage subscription button) so we don't +# need additional caching here. The 503 fallback keeps the route +# safe to enable in environments without LS configured. +_LS_API_BASE = "https://api.lemonsqueezy.com/v1" + + +def _ls_api_key() -> str: + return os.getenv("AIJOBAGENT_LEMONSQUEEZY_API_KEY", "").strip() + + +@router.post("/webhooks/lemonsqueezy") +async def lemonsqueezy_webhook(request: Request) -> dict[str, Any]: + """Handle a single LS subscription webhook delivery. + + Body shape is documented at https://docs.lemonsqueezy.com/help/webhooks. + We don't bind to a pydantic model here because: + * The signature is computed over the EXACT raw bytes; FastAPI's + request body parsing would normalize whitespace and produce + a signature mismatch. + * LS may add new fields without warning; we only need a few + keys (event_name, subscription_id, user_id, variant_id, + status, renews_at, ends_at) and want to be forward- + compatible. + + The X-Signature header is hex-encoded HMAC-SHA256; the handler + in `backend.webhooks.lemonsqueezy.verify_signature` does a + constant-time compare with the AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET. + """ + raw_body = await request.body() + signature = request.headers.get("X-Signature", "") or "" + + try: + result = process_webhook(raw_body=raw_body, signature=signature) + except WebhookConfigError as exc: + # The endpoint is intentionally offline (no webhook secret + # configured). 503 with Retry-After tells LS to back off + # without permanently failing the delivery -- once the secret + # is set on the VPS and the dashboard webhook is registered, + # LS will pick up retries from where it left off. + logger.warning("lemonsqueezy_webhook_not_configured: %s", exc) + raise HTTPException( + status_code=503, + detail="Lemon Squeezy webhook is not configured on this environment.", + headers={"Retry-After": "300"}, + ) + except InvalidWebhookSignature as exc: + # 401 stops LS from retrying immediately -- a bad signature + # is a configuration mismatch, not a transient failure, and + # retrying won't make it pass. The webhook secret in the LS + # dashboard must match the env var on this server; rotate + # both together when it changes. + logger.warning("lemonsqueezy_webhook_invalid_signature: %s", exc) + raise HTTPException(status_code=401, detail="Invalid webhook signature.") + except Exception: # noqa: BLE001 - unexpected handler failure + # Truly unexpected internal errors propagate as 500 so LS + # retries -- this is the ONE failure mode where we want a + # redelivery (transient Supabase outage, network blip). + logger.exception("lemonsqueezy_webhook_internal_error") + raise + + return result + + +# ─── /billing/portal ──────────────────────────────────────────────────── + + +@router.post("/billing/portal") +def get_billing_portal_url( + request: Request, + auth_tokens=Depends(get_optional_auth_tokens), +): + """Return a one-time LS customer portal URL for the signed-in user. + + Implementation note: LS exposes + GET /v1/customers/{id} which returns an `urls.customer_portal` + field with a signed JWT URL valid for ~24h. We don't need to call + a dedicated "create portal session" endpoint -- that's a Stripe + pattern; LS just returns a long-lived signed URL on the customer + resource directly. + + Returns: + {"url": ""} on success. + 503 when the API key env var isn't configured (the integration + isn't live yet on this environment). + 404 when the user has no LS customer record (free tier user + clicked Manage subscription -- shouldn't happen because the + frontend gates the button on quota.tier != 'free', but + defensive). + """ + access_token, refresh_token = auth_tokens + api_key = _ls_api_key() + if not api_key: + raise HTTPException( + status_code=503, + detail="Lemon Squeezy is not configured on this environment.", + ) + + if not (access_token and refresh_token): + raise HTTPException( + status_code=401, + detail="Sign in to manage your subscription.", + ) + + try: + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + except AppError: + raise HTTPException( + status_code=401, + detail="Your session has expired. Sign in again.", + ) + + user_id = str(getattr(context.app_user, "id", "") or "") + if not user_id: + raise HTTPException( + status_code=401, + detail="Sign in to manage your subscription.", + ) + + # Lookup the LS customer_id from the subscriptions row. Lazy + # import to keep this route module's import graph small (the + # webhook route doesn't need the subscriptions lookup at all). + from backend.subscriptions import get_active_subscription + + sub = get_active_subscription(user_id) + if sub is None or not sub.processor_customer_id: + raise HTTPException( + status_code=404, + detail="No active subscription found for this account.", + ) + + portal_url = _fetch_customer_portal_url( + api_key=api_key, + customer_id=sub.processor_customer_id, + ) + if not portal_url: + raise HTTPException( + status_code=502, + detail="Lemon Squeezy did not return a portal URL.", + ) + return {"url": portal_url} + + +def _fetch_customer_portal_url(*, api_key: str, customer_id: str) -> str: + """Hit GET /v1/customers/{id} and extract urls.customer_portal. + + Synchronous httpx call (we're inside a regular def route, not an + async one). Short timeout because the user is waiting on a click; + a 5s upper bound is consistent with the rest of the backend's + upstream calls. + + Returns "" on any failure (logged) so the route surfaces 502 to + the user. + """ + try: + import httpx + except ImportError: # pragma: no cover - httpx is in the deps + logger.exception("httpx_not_installed") + return "" + + try: + response = httpx.get( + f"{_LS_API_BASE}/customers/{customer_id}", + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/vnd.api+json", + }, + timeout=5.0, + ) + except Exception: # noqa: BLE001 - network exceptions vary + logger.exception( + "lemonsqueezy_customer_fetch_failed customer_id=%s", customer_id + ) + return "" + + if response.status_code != 200: + logger.warning( + "lemonsqueezy_customer_fetch_non_200 status=%s customer_id=%s", + response.status_code, + customer_id, + ) + return "" + + try: + payload = response.json() + except Exception: # noqa: BLE001 + return "" + + if not isinstance(payload, dict): + return "" + data = payload.get("data") or {} + attributes = data.get("attributes") if isinstance(data, dict) else {} + urls = attributes.get("urls") if isinstance(attributes, dict) else {} + if not isinstance(urls, dict): + return "" + return str(urls.get("customer_portal") or "").strip() diff --git a/backend/routers/health.py b/backend/routers/health.py new file mode 100644 index 0000000..3c8ca19 --- /dev/null +++ b/backend/routers/health.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter + +from backend.config import get_backend_settings + + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health_check(): + settings = get_backend_settings() + return { + "status": "ok", + "service": settings.service_name, + "version": settings.service_version, + "providers": { + "greenhouse": { + "configured": settings.greenhouse_board_count > 0, + "board_count": settings.greenhouse_board_count, + }, + "lever": { + "configured": settings.lever_site_count > 0, + "site_count": settings.lever_site_count, + }, + "ashby": { + "configured": settings.ashby_board_count > 0, + "board_count": settings.ashby_board_count, + }, + "workday": { + "configured": settings.workday_board_count > 0, + "board_count": settings.workday_board_count, + }, + }, + } diff --git a/backend/routers/jobs.py b/backend/routers/jobs.py new file mode 100644 index 0000000..bc1b6c7 --- /dev/null +++ b/backend/routers/jobs.py @@ -0,0 +1,148 @@ +import secrets + +from fastapi import APIRouter, Depends, Header, HTTPException, Request + +from backend import quota +from backend.models import ( + JobResolveRequestModel, + JobResolutionResponseModel, + JobSearchRequestModel, + JobSearchResponseModel, +) +from backend.rate_limit import LIMIT_LLM, limiter +from backend.request_auth import get_optional_auth_tokens +from backend.services.auth_session_service import resolve_authenticated_context +from backend.services.job_cache_service import refresh_cached_jobs +from backend.services.job_search_service import JobSearchService, get_job_search_service +from backend.tiers import resolve_user_tier +from src.config import REFRESH_CACHE_SECRET +from src.errors import AppError + + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.post("/search", response_model=JobSearchResponseModel) +@limiter.limit(LIMIT_LLM) +def search_jobs( + request: Request, + payload: JobSearchRequestModel, + live: bool = False, + service: JobSearchService = Depends(get_job_search_service), + auth_tokens=Depends(get_optional_auth_tokens), +): + """Search the job pool. + + Default path (`live=false`): query the cached_jobs Supabase table + via Postgres full-text -- ~30ms, no upstream load. The cache is + refreshed every 4 hours by /admin/refresh-cache. + + Escape hatch (`?live=true`): bypass the cache and fan out to + every configured Greenhouse / Lever board live. Slower (1-3s) and + costs upstream rate-limit budget -- kept for debugging + 'why doesn't this job appear in the cache?' questions and as a + fallback if the cache misbehaves. + + Quota gate (Step 6 of tier-enforcement): + `job_searches` is monthly: Free 50 / Pro UNLIMITED / + Business UNLIMITED. `check_and_increment` short-circuits when + the tier cap equals UNLIMITED (-1), so Pro / Business never + touch the counter row at all. + + No refund-on-failure here: job search is cheap (FTS read, + 30ms), and from the user's perspective an erroring search + still consumed a "search intent." Charging it matches how the + product surfaces results -- the search box accepted the + query, results were returned (even if empty due to error), + and the user can immediately try another. This is an + intentional divergence from the assistant_turns / + resume_parses / tailored_applications pattern, which gate + LLM-cost-bearing actions where a failure means no work was + actually done. + """ + access_token, refresh_token = auth_tokens + auth_context = None + if access_token and refresh_token: + try: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + except AppError: + # Same defensive fallback the streaming assistant uses: + # an auth-resolve failure shouldn't block an anonymous + # search flow. The user just doesn't get metered. + auth_context = None + + app_user = getattr(auth_context, "app_user", None) if auth_context is not None else None + tier = resolve_user_tier(app_user) + quota_user_id = str(getattr(app_user, "id", "") or "") if app_user is not None else "" + if quota_user_id: + # Pro / Business have UNLIMITED job_searches; check_and_increment + # short-circuits on UNLIMITED so the row write is skipped. + # Free's cap of 50 enforces here; raising propagates to the + # global QuotaExceededError handler -> canonical 429. + quota.check_and_increment("job_searches", quota_user_id, tier) + + domain_query = payload.to_domain() + result = service.search(domain_query) if live else service.search_cached(domain_query) + return JobSearchResponseModel.from_domain(result) + + +@router.post("/resolve", response_model=JobResolutionResponseModel) +@limiter.limit(LIMIT_LLM) +def resolve_job_url( + request: Request, + payload: JobResolveRequestModel, + service: JobSearchService = Depends(get_job_search_service), +): + result = service.resolve_url(payload.url) + return JobResolutionResponseModel.from_domain(result) + + +# ----- Admin: cached_jobs refresh --------------------------------------- +# Triggered by Supabase pg_cron via pg_net.http_post with the +# REFRESH_CACHE_SECRET as the bearer token. Not exposed to end users. +# Rate-limited too — even if the secret leaks, the per-IP limiter +# protects the upstream providers from being hammered. + +admin_router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _verify_refresh_secret(authorization: str | None = Header(default=None)) -> None: + """Bearer-token auth for admin endpoints. + + Constant-time comparison via secrets.compare_digest defends against + timing oracles (probably overkill at our threat model but free). + Returns 503 if the server has no REFRESH_CACHE_SECRET configured — + we'd rather fail closed than open. + """ + if not REFRESH_CACHE_SECRET: + raise HTTPException( + status_code=503, + detail="Refresh-cache secret not configured on the server.", + ) + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing bearer token.") + presented = authorization[len("bearer ") :].strip() + if not secrets.compare_digest(presented, REFRESH_CACHE_SECRET): + raise HTTPException(status_code=401, detail="Invalid refresh-cache token.") + + +@admin_router.post("/refresh-cache") +def refresh_cache( + request: Request, + _: None = Depends(_verify_refresh_secret), +): + """Refresh cached_jobs from all configured providers. + + This is what Supabase pg_cron hits every 4 hours (six times a + day on the `0 */4 * * *` schedule). Returns the + structured refresh report (see `refresh_cached_jobs`) so cron + output can be inspected when something goes wrong. + """ + try: + report = refresh_cached_jobs() + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + return report diff --git a/backend/routers/workspace.py b/backend/routers/workspace.py new file mode 100644 index 0000000..0c2f2a7 --- /dev/null +++ b/backend/routers/workspace.py @@ -0,0 +1,717 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse + +from backend.rate_limit import LIMIT_HEAVY, LIMIT_LLM, LIMIT_PARSE, limiter +from backend.request_auth import get_optional_auth_tokens +from backend.services.auth_session_service import ( + build_openai_service_for_context, + resolve_authenticated_context, +) +from backend.services.resume_builder_persistence_service import ( + clear_resume_builder_session, + hydrate_resume_builder_session_if_needed, + load_latest_resume_builder_session, + persist_resume_builder_session, +) +from backend.services.workspace_persistence_service import ( + load_saved_workspace_snapshot, + save_workspace_snapshot, +) +from backend.services.saved_jobs_service import ( + list_saved_jobs, + remove_saved_job, + save_saved_job, +) +from backend.services.artifact_export_service import export_workspace_artifact +from backend.services.artifact_export_service import preview_workspace_artifact +from backend.services.resume_builder_service import ( + answer_resume_builder_message, + commit_resume_builder_session, + export_resume_builder_artifact, + generate_resume_builder_resume, + start_resume_builder_session, + update_resume_builder_session, +) +from backend.services.workspace_quota_service import ( + WorkspaceQuotaAuthRequired, + get_workspace_quota_snapshot, +) +from backend.services.workspace_service import ( + answer_workspace_question, + parse_job_description_upload, + parse_resume_upload, + prepare_stream_workspace_question, + run_workspace_analysis, + stream_workspace_question, +) +from backend.services.workspace_run_jobs import ( + JOB_RETRY_AFTER_SECONDS, + WorkspaceRunJobCapacityError, + get_workspace_analysis_job, + start_workspace_analysis_job, +) +from backend.workspace_models import ( + ResumeBuilderExportRequestModel, + ResumeBuilderMessageRequestModel, + ResumeBuilderSessionRequestModel, + ResumeBuilderUpdateRequestModel, + SavedJobRequestModel, + UploadedFilePayloadModel, + WorkspaceAnalyzeRequestModel, + WorkspaceAssistantRequestModel, + WorkspaceArtifactExportRequestModel, + WorkspaceArtifactPreviewRequestModel, + WorkspaceSaveRequestModel, + WorkspaceAnalyzeJobCreatedResponseModel, + WorkspaceAnalyzeJobStatusResponseModel, +) +from src.errors import AppError, QuotaExceededError + + +router = APIRouter(prefix="/workspace", tags=["workspace"]) + + +def _raise_http_error(error: AppError): + # QuotaExceededError is an AppError subclass but it needs to + # propagate out of the route so the global FastAPI handler can + # build the canonical 429 payload. Re-raise so the exception + # handler chain wins over the generic 400-on-AppError fallback. + if isinstance(error, QuotaExceededError): + raise error + raise HTTPException(status_code=400, detail=error.user_message) + + +def _resolve_openai_service(access_token: str, refresh_token: str): + """Best-effort OpenAIService for an authenticated request. + + Returns None when tokens are missing OR the auth/openai construction + fails — caller (resume builder, etc.) treats None as "no LLM + available" and falls back to the deterministic path.""" + if not (access_token and refresh_token): + return None + try: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + except Exception: + return None + if auth_context is None: + return None + try: + openai_service, _ = build_openai_service_for_context(auth_context) + except Exception: + return None + return openai_service + + +def _attach_persistence_status( + response: dict, + persist_result: dict | None, + *, + access_token: str, + refresh_token: str, +) -> dict: + """Tag a resume-builder route response with the persistence outcome. + + Tri-state so the UI can communicate clearly: + - "saved": signed in + Supabase upsert succeeded + - "skipped": signed in but persistence failed (Supabase + unreachable, RLS reject, payload export error). + The user's draft is in-memory only and at risk + from a container restart. + - "unauthenticated": no auth tokens; persistence was never + attempted. Surface a "sign in to save" prompt + in the UI instead of a generic skip. + + Also forwards `expires_at` (ISO timestamp from the saved row) when + available so the UI can render a "refreshes through X" hint next + to the indicator. The TTL refreshes on every save, so the value + represents the latest write's expiry. + + `persist_result` is the dict returned by + persist_resume_builder_session; treat None as a missing call. + """ + if not (access_token and refresh_token): + response["persistence_status"] = "unauthenticated" + return response + raw_status = (persist_result or {}).get("status", "skipped") + response["persistence_status"] = ( + "saved" if raw_status == "saved" else "skipped" + ) + expires_at = (persist_result or {}).get("expires_at") or "" + if expires_at: + response["expires_at"] = expires_at + return response + + +@router.post("/resume/upload") +@limiter.limit(LIMIT_PARSE) +def upload_resume( + request: Request, + payload: UploadedFilePayloadModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + """Parse an uploaded resume into a CandidateProfile. + + Auth tokens are optional (anonymous users can still preview a + parse) but are threaded through to ``parse_resume_upload`` so the + resume_parses quota gate can attribute the credit. The gate + short-circuits cleanly when tokens are empty. + """ + access_token, refresh_token = auth_tokens + try: + return parse_resume_upload( + filename=payload.filename, + mime_type=payload.mime_type, + content_base64=payload.content_base64, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except AppError as error: + _raise_http_error(error) + + +@router.post("/job-description/upload") +@limiter.limit(LIMIT_PARSE) +def upload_job_description(request: Request, payload: UploadedFilePayloadModel): + try: + return parse_job_description_upload( + filename=payload.filename, + mime_type=payload.mime_type, + content_base64=payload.content_base64, + ) + except AppError as error: + _raise_http_error(error) + + +@router.post("/resume-builder/start") +@limiter.limit(LIMIT_LLM) +def start_resume_builder_route(request: Request, auth_tokens=Depends(get_optional_auth_tokens)): + access_token, refresh_token = auth_tokens + try: + # The resume_builder_sessions quota gate fires inside + # start_resume_builder_session -- we pass the auth tokens so it + # can attribute the credit. Free tier consumes a LIFETIME slot + # (cap 1, one onboarding ever); Pro and Business consume from + # a MONTHLY slot pool (3 / 15). The lifetime/monthly switch + # lives inside the service so the route stays tier-agnostic. + payload = start_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + persist_result = persist_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload["session_id"], + ) + return _attach_persistence_status( + payload, + persist_result, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.get("/resume-builder/latest") +def load_resume_builder_route(auth_tokens=Depends(get_optional_auth_tokens)): + access_token, refresh_token = auth_tokens + return load_latest_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + + +@router.post("/resume-builder/message") +@limiter.limit(LIMIT_LLM) +def answer_resume_builder_route( + request: Request, + payload: ResumeBuilderMessageRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + hydrate_resume_builder_session_if_needed( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + openai_service = _resolve_openai_service( + access_token or "", + refresh_token or "", + ) + response = answer_resume_builder_message( + session_id=payload.session_id, + message=payload.message, + openai_service=openai_service, + ) + persist_result = persist_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + return _attach_persistence_status( + response, + persist_result, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + +@router.post("/resume-builder/generate") +@limiter.limit(LIMIT_HEAVY) +def generate_resume_builder_route( + request: Request, + payload: ResumeBuilderSessionRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + hydrate_resume_builder_session_if_needed( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + # LLM-first structuring at generate time — falls back to regex + # parser inside the service when the service is None or errors. + openai_service = _resolve_openai_service( + access_token or "", + refresh_token or "", + ) + response = generate_resume_builder_resume( + session_id=payload.session_id, + openai_service=openai_service, + ) + persist_result = persist_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + return _attach_persistence_status( + response, + persist_result, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + +@router.post("/resume-builder/update") +@limiter.limit(LIMIT_LLM) +def update_resume_builder_route( + request: Request, + payload: ResumeBuilderUpdateRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + hydrate_resume_builder_session_if_needed( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + response = update_resume_builder_session( + session_id=payload.session_id, + draft_updates=payload.draft_profile, + ) + persist_result = persist_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + return _attach_persistence_status( + response, + persist_result, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + +@router.post("/resume-builder/commit") +@limiter.limit(LIMIT_HEAVY) +def commit_resume_builder_route( + request: Request, + payload: ResumeBuilderSessionRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + hydrate_resume_builder_session_if_needed( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + openai_service = _resolve_openai_service( + access_token or "", + refresh_token or "", + ) + response = commit_resume_builder_session( + session_id=payload.session_id, + openai_service=openai_service, + ) + clear_resume_builder_session( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + return response + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + +@router.post("/resume-builder/export") +@limiter.limit(LIMIT_HEAVY) +def export_resume_builder_route( + request: Request, + payload: ResumeBuilderExportRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + """Phase 5: download the builder's generated base resume. + + Auth-gated like the other resume-builder routes — same hydrate + + persistence story so a container restart between Generate and + Download doesn't leave the user staring at a 400. Reuses + `export_pdf_bytes` / `export_docx_bytes` via the service-layer + `export_resume_builder_artifact()` helper. + """ + access_token, refresh_token = auth_tokens + try: + hydrate_resume_builder_session_if_needed( + access_token=access_token or "", + refresh_token=refresh_token or "", + session_id=payload.session_id, + ) + openai_service = _resolve_openai_service( + access_token or "", + refresh_token or "", + ) + return export_resume_builder_artifact( + session_id=payload.session_id, + export_format=payload.export_format, + theme=payload.theme, + openai_service=openai_service, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + +@router.post("/analyze") +@limiter.limit(LIMIT_HEAVY) +def analyze_workspace( + request: Request, + payload: WorkspaceAnalyzeRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return run_workspace_analysis( + resume_text=payload.resume_text, + resume_filetype=payload.resume_filetype, + resume_source=payload.resume_source, + job_description_text=payload.job_description_text, + imported_job_posting=payload.imported_job_posting, + run_assisted=payload.run_assisted, + premium=payload.premium, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except AppError as error: + _raise_http_error(error) + + +@router.post( + "/analyze-jobs", + response_model=WorkspaceAnalyzeJobCreatedResponseModel, +) +@limiter.limit(LIMIT_HEAVY) +def start_workspace_analysis_job_route( + request: Request, + payload: WorkspaceAnalyzeRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return start_workspace_analysis_job( + resume_text=payload.resume_text, + resume_filetype=payload.resume_filetype, + resume_source=payload.resume_source, + job_description_text=payload.job_description_text, + imported_job_posting=payload.imported_job_posting, + premium=payload.premium, + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except WorkspaceRunJobCapacityError: + raise HTTPException( + status_code=503, + detail=( + "The workspace is busy running other agentic workflows right " + "now. Please try again in a few seconds." + ), + headers={"Retry-After": str(JOB_RETRY_AFTER_SECONDS)}, + ) + except QuotaExceededError: + # Let the global handler build the structured 429. Without + # this re-raise the surrounding `except WorkspaceRunJobCapacityError` + # path would still allow QuotaExceededError to propagate, but + # being explicit here documents that quota rejection is the + # second supported failure mode on this surface. + raise + + +@router.get("/quota") +def get_workspace_quota_route(auth_tokens=Depends(get_optional_auth_tokens)): + """Per-user quota snapshot for the workspace UI (Step 7b). + + Drives the Premium toggle's enabled / disabled state, the + per-counter "X of Y remaining this month" indicators, and the + upgrade CTA URL. Read-only — calling this endpoint never + increments a counter, never writes to Supabase, never burns + quota credit. Safe to call on every workspace mount and after + every workflow run, which the frontend does to keep the + indicators in sync with the actual backend state. + + Anonymous callers get a 401 — the snapshot only makes sense for + an authenticated user and we don't want to leak per-tier cap + numbers on an unauthenticated probe. The frontend's API client + handles 401 by prompting re-auth and skipping the quota render. + """ + access_token, refresh_token = auth_tokens + try: + return get_workspace_quota_snapshot( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except WorkspaceQuotaAuthRequired: + # The exception's message is already user-facing, but the + # error_messages lint specifically forbids `str(exc)` as a + # detail source (it allows an exception's raw repr to leak + # if a future refactor changes the type). Use a fixed string + # literal so the lint stays clean. Matches the message text + # WorkspaceQuotaAuthRequired itself uses at both raise sites. + raise HTTPException( + status_code=401, + detail="Sign in to view your workspace quota.", + ) + except AppError as error: + _raise_http_error(error) + + +@router.get( + "/analyze-jobs/{job_id}", + response_model=WorkspaceAnalyzeJobStatusResponseModel, +) +def get_workspace_analysis_job_route(job_id: str): + payload = get_workspace_analysis_job(job_id) + if payload is None: + # `_JOBS` is process-local, so a container restart mid-run drops + # the job state permanently. The frontend polling hook surfaces + # `detail` directly to the user, so spell out the cause + the + # recovery action instead of a bare "not found". + raise HTTPException( + status_code=404, + detail=( + "This workflow run is no longer available — the server may " + "have restarted while it was running. Please run the workflow " + "again." + ), + ) + return payload + + +@router.post("/assistant/answer") +@limiter.limit(LIMIT_LLM) +def answer_assistant_question( + request: Request, + payload: WorkspaceAssistantRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return answer_workspace_question( + question=payload.question, + current_page=payload.current_page, + workspace_state=( + payload.workspace_state.model_dump() + if payload.workspace_state + else None + ), + workspace_snapshot=payload.workspace_snapshot, + history=[item.model_dump() for item in payload.history], + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except AppError as error: + _raise_http_error(error) + + +@router.post("/assistant/answer/stream") +@limiter.limit(LIMIT_LLM) +def stream_assistant_answer( + request: Request, + payload: WorkspaceAssistantRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + """Server-Sent Events sibling of ``/assistant/answer``. + + Same request body, but the response is ``text/event-stream`` and + emits ``meta`` → ``delta``... → ``followups`` → ``done`` events + (or ``error`` → ``done`` on failure). See + ``stream_workspace_question`` for the event contract. + + The quota gate (assistant_turns) runs in + ``prepare_stream_workspace_question`` BEFORE StreamingResponse is + constructed. That keeps a quota rejection out of the SSE channel + entirely — the global QuotaExceededError handler in backend.app + converts it to the canonical 429 JSON the same way the sync + surface does. Mixing a 429 into an open ``text/event-stream`` is + not supported by the HTTP spec or by browsers, so the gate has to + win the race against StreamingResponse's status-line commit. + + The ``X-Accel-Buffering: no`` header tells Caddy (and any other + well-behaved reverse proxy) to flush each frame immediately + instead of buffering the response. The Caddyfile also sets + ``flush_interval -1`` for belt-and-braces. + """ + access_token, refresh_token = auth_tokens + prepared = prepare_stream_workspace_question( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + return StreamingResponse( + stream_workspace_question( + question=payload.question, + current_page=payload.current_page, + workspace_state=( + payload.workspace_state.model_dump() + if payload.workspace_state + else None + ), + workspace_snapshot=payload.workspace_snapshot, + history=[item.model_dump() for item in payload.history], + prepared=prepared, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/save") +def save_workspace_route( + payload: WorkspaceSaveRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return save_workspace_snapshot( + access_token=access_token or "", + refresh_token=refresh_token or "", + workspace_snapshot=payload.workspace_snapshot, + ) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) + except RuntimeError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.get("/saved") +def load_saved_workspace_route(auth_tokens=Depends(get_optional_auth_tokens)): + access_token, refresh_token = auth_tokens + try: + return load_saved_workspace_snapshot( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except RuntimeError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.get("/saved-jobs") +def list_saved_jobs_route(auth_tokens=Depends(get_optional_auth_tokens)): + access_token, refresh_token = auth_tokens + try: + return list_saved_jobs( + access_token=access_token or "", + refresh_token=refresh_token or "", + ) + except RuntimeError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.post("/saved-jobs") +def save_saved_job_route( + payload: SavedJobRequestModel, + auth_tokens=Depends(get_optional_auth_tokens), +): + access_token, refresh_token = auth_tokens + try: + return save_saved_job( + access_token=access_token or "", + refresh_token=refresh_token or "", + job_posting=payload.job_posting, + ) + except RuntimeError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.delete("/saved-jobs/{job_id}") +def remove_saved_job_route(job_id: str, auth_tokens=Depends(get_optional_auth_tokens)): + access_token, refresh_token = auth_tokens + try: + return remove_saved_job( + access_token=access_token or "", + refresh_token=refresh_token or "", + job_id=job_id, + ) + except RuntimeError as error: + raise HTTPException(status_code=400, detail=str(error)) + except AppError as error: + _raise_http_error(error) + + +@router.post("/artifacts/export") +@limiter.limit(LIMIT_PARSE) +def export_workspace_artifact_route(request: Request, payload: WorkspaceArtifactExportRequestModel): + try: + return export_workspace_artifact( + workspace_snapshot=payload.workspace_snapshot, + artifact_kind=payload.artifact_kind, + export_format=payload.export_format, + resume_theme=payload.resume_theme, + cover_letter_theme=payload.cover_letter_theme, + ) + except AppError as error: + _raise_http_error(error) + + +@router.post("/artifacts/preview") +@limiter.limit(LIMIT_PARSE) +def preview_workspace_artifact_route(request: Request, payload: WorkspaceArtifactPreviewRequestModel): + try: + return preview_workspace_artifact( + workspace_snapshot=payload.workspace_snapshot, + artifact_kind=payload.artifact_kind, + resume_theme=payload.resume_theme, + cover_letter_theme=payload.cover_letter_theme, + ) + except AppError as error: + _raise_http_error(error) diff --git a/backend/run_traces.py b/backend/run_traces.py new file mode 100644 index 0000000..8359478 --- /dev/null +++ b/backend/run_traces.py @@ -0,0 +1,258 @@ +"""Cost-per-LLM-call recorder for AI Job Application Agent. + +Each call to ``OpenAIService.run_*`` records one row in +``aijobagent_run_traces`` (see ``docs/sql/supabase-run-traces.sql``) +carrying prompt + completion tokens and a USD cost computed from the +pricing map in ``src/openai_service``. The point is tier-margin +validation — every model-routing decision should be grounded in actual +$ spent per task, not estimated COGS. + +This module mirrors the structure of ``backend/quota.py``: + + * A ``_SupabaseRunTracesBackend`` that lazily creates a service-role + Supabase client and writes rows via the standard + ``client.table(...).insert(...)`` path. The migration provides an + RLS policy that lets users read their own rows; writes bypass RLS + because we use the service-role key, identical to the quota RPC. + * A ``_InMemoryRunTracesBackend`` fallback when Supabase isn't + configured (CI without secrets, local dev). The fallback is + process-local — production must run with the Supabase client. + * A single public ``record_trace`` helper that the OpenAIService + bridge can invoke without knowing which backend is active. + +Cost computation lives in ``src/openai_service`` next to the rest of +the model-routing surface (pricing-map + token-usage record). This +module is the persistence layer. +""" +from __future__ import annotations + +import logging +import os +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from src.config import SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL + + +try: # supabase is optional in some test paths (parity with quota.py) + from supabase import create_client as _create_supabase_client # type: ignore +except Exception: # pragma: no cover - defensive import + _create_supabase_client = None # type: ignore + + +logger = logging.getLogger(__name__) + + +# Module-level config knob. The table name should match the DDL in +# ``docs/sql/supabase-run-traces.sql``; the env-var override exists for +# the same reason the quota table has one — running parallel staging +# environments off a single Supabase project without colliding rows. +_RUN_TRACES_TABLE = os.getenv("SUPABASE_RUN_TRACES_TABLE", "aijobagent_run_traces").strip() + + +# ── Public data shape ───────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class TraceRecord: + """A single recorded LLM call. + + Mirrors the SQL columns 1:1. ``trace_id`` is filled in by the DB + default; the application doesn't need to compute it client-side. + """ + + user_id: Optional[str] + task_name: str + model_name: str + prompt_tokens: int = 0 + completion_tokens: int = 0 + cost_usd: float = 0.0 + success: bool = True + created_at: str = "" + + +# ── Backend abstraction (parity with backend.quota) ────────────────────── + + +class _InMemoryRunTracesBackend: + """Process-local backend used when Supabase isn't configured. + + Mirrors ``_InMemoryQuotaBackend`` in ``backend/quota.py`` — tests + and local dev still hit the same code path that production uses, + they just write into an in-process list instead of Postgres. + + The store is exposed via ``rows()`` for assertions; ``reset()`` + wipes it between test cases. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._rows: list[dict] = [] + + def reset(self) -> None: + with self._lock: + self._rows.clear() + + def rows(self) -> list[dict]: + with self._lock: + return list(self._rows) + + def insert(self, record: TraceRecord) -> None: + with self._lock: + row = { + "user_id": record.user_id, + "task_name": record.task_name, + "model_name": record.model_name, + "prompt_tokens": int(record.prompt_tokens), + "completion_tokens": int(record.completion_tokens), + "cost_usd": float(record.cost_usd), + "success": bool(record.success), + "created_at": record.created_at or datetime.now(timezone.utc).isoformat(), + } + self._rows.append(row) + + +class _SupabaseRunTracesBackend: + """Service-role-backed run-traces persister. + + Insert-only — reads happen from the frontend / admin dashboard via + RLS-protected ``select`` against the same table. Lazy client init + so importing this module without SUPABASE_URL / SERVICE_ROLE_KEY + doesn't crash; ``is_configured()`` gates which backend + ``_select_backend`` picks. + """ + + def __init__( + self, + *, + supabase_url: str = SUPABASE_URL, + service_role_key: str = SUPABASE_SERVICE_ROLE_KEY, + table_name: str = _RUN_TRACES_TABLE, + ) -> None: + self._url = supabase_url + self._key = service_role_key + self._table = table_name + self._client = None + + def is_configured(self) -> bool: + return bool(self._url and self._key and _create_supabase_client is not None) + + def _require_client(self): + if self._client is None: + self._client = _create_supabase_client(self._url, self._key) + return self._client + + def insert(self, record: TraceRecord) -> None: + client = self._require_client() + row = { + "user_id": record.user_id, + "task_name": record.task_name, + "model_name": record.model_name, + "prompt_tokens": int(record.prompt_tokens), + "completion_tokens": int(record.completion_tokens), + # Postgres numeric accepts a string or a float; passing as + # str avoids any floating-point surprise on the wire. + "cost_usd": "{:.6f}".format(float(record.cost_usd)), + "success": bool(record.success), + } + # ``trace_id`` and ``created_at`` are filled in by column defaults; + # we don't send them client-side so they can't drift from the + # server clock or the gen_random_uuid() default. + try: + client.table(self._table).insert(row).execute() + except Exception: # noqa: BLE001 - boundary translation + # Cost tracking is best-effort: a Supabase outage must not + # turn a successful OpenAI call into a workflow failure. + # Re-raise after logging so the caller can decide to swallow + # (the OpenAI bridge does swallow by design). + logger.exception( + "run_trace_insert_failed task=%s model=%s user_id=%s", + record.task_name, + record.model_name, + record.user_id, + ) + raise + + +# Module-level singletons. Tests reach in via ``reset_in_memory_backend`` +# or by monkeypatching ``_BACKEND`` directly. Production resolves to the +# Supabase backend automatically once the env vars are set. +_IN_MEMORY_BACKEND = _InMemoryRunTracesBackend() +_SUPABASE_BACKEND = _SupabaseRunTracesBackend() + + +def _select_backend(): + if _SUPABASE_BACKEND.is_configured(): + return _SUPABASE_BACKEND + return _IN_MEMORY_BACKEND + + +def reset_in_memory_backend() -> None: + """Wipe the process-local fallback store. Test-only -- production + runs through Supabase and has no equivalent. + """ + _IN_MEMORY_BACKEND.reset() + + +def in_memory_rows() -> list[dict]: + """Read-only access to the in-memory backend's rows. + + Test helper: when a test wants to assert ``record_trace`` actually + persisted a row, it can read here without monkey-patching the + backend object. Production callers never hit this — the path goes + through Supabase. + """ + return _IN_MEMORY_BACKEND.rows() + + +# ── Public API ──────────────────────────────────────────────────────────── + + +def record_trace( + *, + task_name: str, + model_name: str, + prompt_tokens: int, + completion_tokens: int, + cost_usd: float, + user_id: Optional[str] = None, + success: bool = True, +) -> None: + """Insert a row into ``aijobagent_run_traces``. + + Called from the OpenAIService bridge after every successful LLM + response. Best-effort: any backend error is logged and swallowed so + cost-tracking outages don't propagate into a workflow failure. + + ``user_id`` is ``None`` when the caller is unauthenticated (e.g. + the assistant in product-help mode). The DB schema permits NULL on + that column for this reason -- the row is still useful for fleet- + wide aggregates even without a user attribution. + """ + record = TraceRecord( + user_id=user_id, + task_name=task_name or "", + model_name=model_name or "", + prompt_tokens=int(prompt_tokens or 0), + completion_tokens=int(completion_tokens or 0), + cost_usd=float(cost_usd or 0.0), + success=bool(success), + ) + backend = _select_backend() + try: + backend.insert(record) + except Exception: + # Already logged inside the Supabase branch; the in-memory + # branch never raises. Swallow so the caller's hot path stays + # clean. + return + + +__all__ = [ + "TraceRecord", + "record_trace", + "reset_in_memory_backend", + "in_memory_rows", +] diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..833d876 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +"""Backend service layer for job search and future API-owned workflows.""" diff --git a/backend/services/artifact_export_service.py b/backend/services/artifact_export_service.py new file mode 100644 index 0000000..df22375 --- /dev/null +++ b/backend/services/artifact_export_service.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import base64 +from typing import Literal + +from src.cover_letter_builder import build_cover_letter_artifact +from src.errors import InputValidationError +from src.exporters import ( + build_cover_letter_preview_html, + build_resume_preview_html, + export_docx_bytes, + export_pdf_bytes, +) +from src.resume_builder import build_tailored_resume_artifact +from src.workflow_payloads import build_saved_workflow_snapshot_from_data + + +ArtifactKind = Literal["tailored_resume", "cover_letter"] +# DOCX is the new download surface; markdown export was removed in +# Phase 2 of the DOCX export plan. PDF stays for printable copies. +ExportFormat = Literal["pdf", "docx"] + +_DOCX_MIME_TYPE = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +) + + +def _encode_bytes(payload: bytes): + return base64.b64encode(payload).decode("ascii") + + +_SUPPORTED_THEMES = {"classic_ats", "professional_neutral"} + + +def _resolve_theme(theme_name: str | None): + return theme_name if theme_name in _SUPPORTED_THEMES else "classic_ats" + + +# Back-compat alias — older callers may still import this name. +def _resolve_resume_theme(theme_name: str): + return _resolve_theme(theme_name) + + +def _hydrate_snapshot(workspace_snapshot: dict): + snapshot = build_saved_workflow_snapshot_from_data(dict(workspace_snapshot or {})) + if snapshot is None: + raise InputValidationError( + "Run the workspace flow before exporting artifacts." + ) + return snapshot + + +def _build_artifact_set(workspace_snapshot: dict, resume_theme: str, cover_letter_theme: str): + snapshot = _hydrate_snapshot(workspace_snapshot) + tailored_resume = build_tailored_resume_artifact( + snapshot.candidate_profile, + snapshot.job_description, + snapshot.fit_analysis, + snapshot.tailored_draft, + agent_result=snapshot.agent_result, + theme=resume_theme, + ) + cover_letter = build_cover_letter_artifact( + snapshot.candidate_profile, + snapshot.job_description, + snapshot.fit_analysis, + snapshot.tailored_draft, + agent_result=snapshot.agent_result, + theme=cover_letter_theme, + ) + return { + "tailored_resume": tailored_resume, + "cover_letter": cover_letter, + } + + +def _resume_export_file_name(filename_stem: str, resume_theme: str, extension: str): + return "{stem}.{extension}".format( + stem=filename_stem or "tailored-resume", + extension=extension, + ) + + +def export_workspace_artifact( + *, + workspace_snapshot: dict | None, + artifact_kind: ArtifactKind, + export_format: ExportFormat, + resume_theme: str = "classic_ats", + cover_letter_theme: str = "classic_ats", +): + theme_name = _resolve_theme(resume_theme) + cover_theme_name = _resolve_theme(cover_letter_theme) + artifacts = _build_artifact_set(workspace_snapshot or {}, theme_name, cover_theme_name) + + artifact = artifacts[artifact_kind] + if export_format == "pdf": + payload = export_pdf_bytes(artifact) + mime_type = "application/pdf" + file_name = ( + _resume_export_file_name(artifact.filename_stem, theme_name, "pdf") + if artifact_kind == "tailored_resume" + else artifact.filename_stem + ".pdf" + ) + elif export_format == "docx": + payload = export_docx_bytes(artifact) + mime_type = _DOCX_MIME_TYPE + file_name = ( + _resume_export_file_name(artifact.filename_stem, theme_name, "docx") + if artifact_kind == "tailored_resume" + else artifact.filename_stem + ".docx" + ) + else: + raise InputValidationError("Choose a supported export format.") + + return { + "status": "ready", + "artifact_kind": artifact_kind, + "export_format": export_format, + "file_name": file_name, + "mime_type": mime_type, + "content_base64": _encode_bytes(payload), + "resume_theme": theme_name, + "cover_letter_theme": cover_theme_name, + "artifact_title": artifact.title, + } + + +def preview_workspace_artifact( + *, + workspace_snapshot: dict | None, + artifact_kind: Literal["tailored_resume", "cover_letter"], + resume_theme: str = "classic_ats", + cover_letter_theme: str = "classic_ats", +): + theme_name = _resolve_theme(resume_theme) + cover_theme_name = _resolve_theme(cover_letter_theme) + artifacts = _build_artifact_set(workspace_snapshot or {}, theme_name, cover_theme_name) + artifact = artifacts[artifact_kind] + + if artifact_kind == "tailored_resume": + html = build_resume_preview_html(artifact) + else: + html = build_cover_letter_preview_html(artifact) + + return { + "status": "ready", + "artifact_kind": artifact_kind, + "resume_theme": theme_name, + "cover_letter_theme": cover_theme_name, + "artifact_title": artifact.title, + "html": html, + } diff --git a/backend/services/auth_cookies.py b/backend/services/auth_cookies.py new file mode 100644 index 0000000..2963866 --- /dev/null +++ b/backend/services/auth_cookies.py @@ -0,0 +1,104 @@ +"""HttpOnly auth cookie helpers. + +Production auth flow stores tokens in two HttpOnly cookies that the +browser attaches automatically on every request to the backend (or any +same-site origin via the frontend's Next.js proxy). The frontend never +touches the raw tokens, so XSS cannot exfiltrate them. + +The cookies are scoped to ``settings.auth_cookie_domain`` so the same +session is valid across the landing (``job-application-copilot.xyz``) +and workspace (``app.job-application-copilot.xyz``) subdomains. On +localhost the domain is left empty, which makes the cookies host-only +and works without further configuration. +""" + +from __future__ import annotations + +from fastapi import Response + +from backend.config import get_backend_settings + +ACCESS_TOKEN_COOKIE = "ja_access_token" +REFRESH_TOKEN_COOKIE = "ja_refresh_token" + +# Sliding expiries. Refresh token controls "stay signed in" duration; the +# access cookie mirrors it so a quiet tab doesn't lose only half its +# credentials. AuthService rotates tokens on restore (Supabase mints a +# fresh access JWT from the still-valid refresh JWT), and the router +# re-issues both cookies on every restore call. +# +# The COOKIE's max_age controls when the browser purges the cookie; +# the JWT inside has its own short-lived exp claim (Supabase rotates +# on restore). Keeping both cookies at the same max_age means a user +# returning within the refresh window always has both cookies present +# for the restore call to use — without that, a 7+ day quiet user +# would lose their access cookie while the refresh cookie is still +# valid and `resolve_authenticated_context` would reject the +# half-credentialed restore, forcing an unnecessary re-login (Codex +# P1 finding on PR #2, May 2026). +_ACCESS_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 # 30 days +_REFRESH_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 # 30 days + + +def _cookie_kwargs() -> dict: + settings = get_backend_settings() + kwargs: dict = { + "httponly": True, + "secure": settings.auth_cookie_secure, + "samesite": settings.auth_cookie_samesite, + "path": "/", + } + if settings.auth_cookie_domain: + kwargs["domain"] = settings.auth_cookie_domain + return kwargs + + +def set_auth_cookies( + response: Response, + access_token: str, + refresh_token: str, +) -> None: + """Attach HttpOnly access/refresh cookies to ``response``. + + No-ops if either token is empty so we never overwrite a good cookie + with a blank one. + """ + normalized_access = (access_token or "").strip() + normalized_refresh = (refresh_token or "").strip() + if not normalized_access or not normalized_refresh: + return + + kwargs = _cookie_kwargs() + response.set_cookie( + key=ACCESS_TOKEN_COOKIE, + value=normalized_access, + max_age=_ACCESS_TOKEN_MAX_AGE_SECONDS, + **kwargs, + ) + response.set_cookie( + key=REFRESH_TOKEN_COOKIE, + value=normalized_refresh, + max_age=_REFRESH_TOKEN_MAX_AGE_SECONDS, + **kwargs, + ) + + +def clear_auth_cookies(response: Response) -> None: + """Remove auth cookies on the client. + + ``delete_cookie`` must be called with the same path/domain that was + used to set the cookie; otherwise the browser keeps the original + cookie around and the user appears signed in indefinitely. + """ + settings = get_backend_settings() + domain = settings.auth_cookie_domain or None + response.delete_cookie( + key=ACCESS_TOKEN_COOKIE, + path="/", + domain=domain, + ) + response.delete_cookie( + key=REFRESH_TOKEN_COOKIE, + path="/", + domain=domain, + ) diff --git a/backend/services/auth_handoff_service.py b/backend/services/auth_handoff_service.py new file mode 100644 index 0000000..bb9253e --- /dev/null +++ b/backend/services/auth_handoff_service.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import threading +import time +import uuid +from dataclasses import dataclass, field +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from src.errors import InputValidationError + +from backend.services.auth_session_service import restore_authenticated_session + + +HANDOFF_TTL_SECONDS = 60 + + +@dataclass +class WorkspaceHandoffRecord: + access_token: str + refresh_token: str + created_at: float = field(default_factory=time.time) + + +_HANDOFFS: dict[str, WorkspaceHandoffRecord] = {} +_LOCK = threading.Lock() + + +def _prune_handoffs() -> None: + cutoff = time.time() - HANDOFF_TTL_SECONDS + stale_tokens = [ + token + for token, record in _HANDOFFS.items() + if record.created_at < cutoff + ] + for token in stale_tokens: + _HANDOFFS.pop(token, None) + + +def _append_handoff_query(target_url: str, handoff_token: str) -> str: + url = str(target_url or "").strip() + parsed = urlsplit(url) + query_items = [ + (key, value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + if key != "handoff" + ] + query_items.append(("handoff", handoff_token)) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(query_items), + parsed.fragment, + ) + ) + + +def start_workspace_handoff( + *, + access_token: str, + refresh_token: str, + target_url: str, +) -> dict: + normalized_access = str(access_token or "").strip() + normalized_refresh = str(refresh_token or "").strip() + normalized_target = str(target_url or "").strip() + + if not normalized_access or not normalized_refresh: + raise InputValidationError("Sign in with Google before entering the workspace.") + if not normalized_target: + raise InputValidationError("A workspace target URL is required.") + + with _LOCK: + _prune_handoffs() + handoff_token = uuid.uuid4().hex + _HANDOFFS[handoff_token] = WorkspaceHandoffRecord( + access_token=normalized_access, + refresh_token=normalized_refresh, + ) + + return { + "status": "ready", + "redirect_url": _append_handoff_query(normalized_target, handoff_token), + } + + +def exchange_workspace_handoff(*, handoff_token: str) -> dict: + normalized_token = str(handoff_token or "").strip() + if not normalized_token: + raise InputValidationError("A workspace handoff token is required.") + + with _LOCK: + _prune_handoffs() + record = _HANDOFFS.pop(normalized_token, None) + + if record is None: + raise InputValidationError( + "That workspace handoff expired. Open the workspace from the landing page again." + ) + + return restore_authenticated_session( + access_token=record.access_token, + refresh_token=record.refresh_token, + ) diff --git a/backend/services/auth_session_service.py b/backend/services/auth_session_service.py new file mode 100644 index 0000000..ce50841 --- /dev/null +++ b/backend/services/auth_session_service.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from typing import Any + +from backend.config import get_backend_settings +from src.auth_service import AuthService, AuthSession +from src.config import ( + AUTH_DEFAULT_ACCOUNT_STATUS, + assisted_workflow_requires_login, + get_default_plan_tier_for_email, +) +from src.errors import AgentExecutionError, AppError, InputValidationError +from src.openai_service import OpenAIService +from src.quota_service import QuotaService +from src.saved_jobs_store import SavedJobsStore +from src.saved_workspace_store import SavedWorkspaceStore +from src.schemas import AppUserRecord +from src.usage_store import UsageStore +from src.user_store import AppUserStore + + +@dataclass +class AuthenticatedContext: + auth_service: AuthService + auth_session: AuthSession + app_user: AppUserRecord + daily_quota: Any | None = None + + +class BackendPkceStorage: + def __init__(self): + self._code_verifier: str | None = None + + def get_item(self, key: str): + if key.endswith("-code-verifier"): + return self._code_verifier + return None + + def set_item(self, key: str, value: str): + if key.endswith("-code-verifier"): + self._code_verifier = str(value or "").strip() or None + + def remove_item(self, key: str): + if key.endswith("-code-verifier"): + self._code_verifier = None + + +def _serialize(value: Any): + if is_dataclass(value): + return {key: _serialize(item) for key, item in asdict(value).items()} + if isinstance(value, dict): + return {key: _serialize(item) for key, item in value.items()} + if isinstance(value, list): + return [_serialize(item) for item in value] + return value + + +def resolve_auth_redirect_url(explicit_redirect_url: str = ""): + normalized = str(explicit_redirect_url or "").strip() + if normalized: + return normalized + settings = get_backend_settings() + return f"{settings.frontend_app_url.rstrip('/')}/workspace" + + +def _build_fallback_app_user_record(auth_session: AuthSession): + return AppUserRecord( + id=auth_session.user.user_id, + email=auth_session.user.email or "", + display_name=auth_session.user.display_name or "", + avatar_url=auth_session.user.avatar_url or "", + created_at="", + last_seen_at="", + plan_tier=get_default_plan_tier_for_email(auth_session.user.email), + account_status=AUTH_DEFAULT_ACCOUNT_STATUS, + ) + + +def _sync_or_build_app_user_record(auth_service: AuthService, auth_session: AuthSession): + user_store = AppUserStore(auth_service) + if user_store.is_configured(): + return user_store.sync_user_record(auth_session) + return _build_fallback_app_user_record(auth_session) + + +def _load_daily_quota( + auth_service: AuthService, + access_token: str, + refresh_token: str, + app_user: AppUserRecord, +): + usage_store = UsageStore(auth_service) + if not usage_store.is_configured(): + return None + quota_service = QuotaService(auth_service, usage_store) + return quota_service.get_daily_quota_status( + access_token, + refresh_token, + app_user.id, + app_user.plan_tier, + ) + + +def _build_authenticated_payload(context: AuthenticatedContext): + usage_store = UsageStore(context.auth_service) + saved_workspace_store = SavedWorkspaceStore(context.auth_service) + saved_jobs_store = SavedJobsStore(context.auth_service) + return { + "authenticated": True, + "session": { + "access_token": context.auth_session.access_token, + "refresh_token": context.auth_session.refresh_token, + }, + "user": _serialize(context.auth_session.user), + "app_user": _serialize(context.app_user), + "daily_quota": _serialize(context.daily_quota) if context.daily_quota else None, + "features": { + "saved_workspace_enabled": saved_workspace_store.is_configured(), + "saved_jobs_enabled": saved_jobs_store.is_configured(), + "usage_tracking_enabled": usage_store.is_configured(), + "assisted_workflow_requires_login": assisted_workflow_requires_login(), + }, + } + + +def start_google_sign_in(*, redirect_url: str): + storage = BackendPkceStorage() + auth_service = AuthService( + redirect_url=resolve_auth_redirect_url(redirect_url), + storage=storage, + ) + request = auth_service.get_google_sign_in_request() + return { + "url": request.url, + "auth_flow": request.auth_flow, + "redirect_url": auth_service.redirect_url, + } + + +def exchange_google_code(*, auth_code: str, auth_flow: str = "", redirect_url: str = ""): + auth_service = AuthService(redirect_url=resolve_auth_redirect_url(redirect_url)) + auth_session = auth_service.exchange_code_for_session( + auth_code, + auth_flow=auth_flow or None, + ) + app_user = _sync_or_build_app_user_record(auth_service, auth_session) + daily_quota = _load_daily_quota( + auth_service, + auth_session.access_token, + auth_session.refresh_token, + app_user, + ) + return _build_authenticated_payload( + AuthenticatedContext( + auth_service=auth_service, + auth_session=auth_session, + app_user=app_user, + daily_quota=daily_quota, + ) + ) + + +def restore_authenticated_session(*, access_token: str, refresh_token: str): + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + return _build_authenticated_payload(context) + + +def sign_out_authenticated_session(*, access_token: str, refresh_token: str): + auth_service = AuthService() + auth_service.sign_out(access_token, refresh_token) + return {"authenticated": False, "status": "signed_out"} + + +def resolve_authenticated_context(*, access_token: str, refresh_token: str): + normalized_access = str(access_token or "").strip() + normalized_refresh = str(refresh_token or "").strip() + if not normalized_access or not normalized_refresh: + raise InputValidationError("Sign in with Google before using this feature.") + + auth_service = AuthService() + auth_session = auth_service.restore_session(normalized_access, normalized_refresh) + app_user = _sync_or_build_app_user_record(auth_service, auth_session) + daily_quota = _load_daily_quota( + auth_service, + normalized_access, + normalized_refresh, + app_user, + ) + return AuthenticatedContext( + auth_service=auth_service, + auth_session=auth_session, + app_user=app_user, + daily_quota=daily_quota, + ) + + +def build_openai_service_for_context(context: AuthenticatedContext): + usage_store = UsageStore(context.auth_service) + if not usage_store.is_configured(): + return OpenAIService(), context.daily_quota + + quota_service = QuotaService(context.auth_service, usage_store) + daily_quota = context.daily_quota or quota_service.get_daily_quota_status( + context.auth_session.access_token, + context.auth_session.refresh_token, + context.app_user.id, + context.app_user.plan_tier, + ) + + if daily_quota and daily_quota.quota_exhausted: + + def quota_checker(): + raise AgentExecutionError( + "Your daily assisted usage limit has been reached. Try again tomorrow or upgrade your plan tier." + ) + + else: + + def quota_checker(): + refreshed_quota = quota_service.get_daily_quota_status( + context.auth_session.access_token, + context.auth_session.refresh_token, + context.app_user.id, + context.app_user.plan_tier, + ) + if refreshed_quota and refreshed_quota.quota_exhausted: + raise AgentExecutionError( + "Your daily assisted usage limit has been reached. Try again tomorrow or upgrade your plan tier." + ) + + def usage_event_recorder(event_payload: dict): + usage_store.record_usage_event( + context.auth_session.access_token, + context.auth_session.refresh_token, + { + **dict(event_payload or {}), + "user_id": context.app_user.id, + }, + ) + + return ( + OpenAIService( + usage_event_recorder=usage_event_recorder, + quota_checker=quota_checker, + # Threading user_id through here lets the cost-tracking + # bridge inside OpenAIService record per-call USD into + # aijobagent_run_traces with the right user attribution. + # See ``src/openai_service.compute_call_cost_usd`` and + # ``backend/run_traces.record_trace``. The unauthenticated + # branch above (`OpenAIService()`) doesn't pass user_id; + # cost rows are skipped in that case by design. + user_id=context.app_user.id, + ), + daily_quota, + ) diff --git a/backend/services/job_cache_service.py b/backend/services/job_cache_service.py new file mode 100644 index 0000000..b303f04 --- /dev/null +++ b/backend/services/job_cache_service.py @@ -0,0 +1,269 @@ +"""Refresh worker for the cached_jobs Supabase table. + +This is what /admin/refresh-cache calls. It: + 1. Iterates the configured job-source adapters (greenhouse, lever). + 2. Calls `fetch_all_postings()` on each — unfiltered firehose. + 3. Bulk-upserts every posting into cached_jobs (keyed on source + job_id). + 4. Runs the smart cleanup: tombstone rows that disappeared upstream + IF a user has saved them, hard-delete them otherwise. + +Designed to be idempotent and crash-safe at any step: + - A partial run leaves cached_jobs in a consistent state (just less + fresh than a full run). + - Errors per source are isolated — one bad board doesn't poison the + rest of the refresh, and crucially doesn't trigger cleanup for + that source (which would otherwise vaporise the cache for a single + failed HTTP call). + +Returns a structured report so the admin endpoint can surface what +happened in JSON. +""" +from __future__ import annotations + +import logging +import time +from datetime import datetime, timezone +from typing import Any + +from src.cached_jobs_store import CachedJobsStore +from src.job_sources.ashby import AshbyJobSourceAdapter +from src.job_sources.greenhouse import GreenhouseJobSourceAdapter +from src.job_sources.lever import LeverJobSourceAdapter +from src.job_sources.workday import WorkdayJobSourceAdapter +from src.logging_utils import get_logger, log_event + + +LOGGER = get_logger(__name__) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _adapters_with_fetch_all(): + """Yield (source_name, adapter) pairs that support bulk fetch. + + Demo source intentionally excluded — its content is local files + that don't need refreshing. Adding a new provider is one line: + instantiate the adapter and append. + """ + yield ("greenhouse", GreenhouseJobSourceAdapter()) + yield ("lever", LeverJobSourceAdapter()) + yield ("ashby", AshbyJobSourceAdapter()) + yield ("workday", WorkdayJobSourceAdapter()) + + +def refresh_cached_jobs( + *, + store: CachedJobsStore | None = None, + adapters: list[tuple[str, Any]] | None = None, +) -> dict: + """Refresh the cached_jobs table from all configured providers. + + Returns a JSON-serializable report of the form: + { + "started_at": "2026-..", + "finished_at": "2026-..", + "duration_seconds": 12.4, + "providers": { + "greenhouse": { + "status": "ok" | "partial" | "error", + "boards_succeeded": 95, + "boards_failed": 5, + "postings_upserted": 4823, + "tombstoned": 12, + "deleted": 47, + "errors": [{"board": "...", "message": "..."}, ...] + }, + "lever": { ... }, + }, + "total_active_after": 5311 + } + """ + cache = store or CachedJobsStore() + if not cache.is_configured(): + raise RuntimeError( + "cached_jobs store is not configured (SUPABASE_URL + " + "SUPABASE_SERVICE_ROLE_KEY required)." + ) + + started_at = _utc_now_iso() + started_perf = time.perf_counter() + # Cutoff for cleanup: anything not touched in this run is a + # candidate. We compute it here (before any fetches) so the + # cleanup query catches every row we DON'T upsert in step 2. + cutoff_iso = started_at + + providers_report: dict[str, dict] = {} + successfully_refreshed: list[str] = [] + + for source_name, adapter in adapters or list(_adapters_with_fetch_all()): + provider_report = { + "status": "ok", + "boards_succeeded": 0, + "boards_failed": 0, + "postings_upserted": 0, + "tombstoned": 0, + "deleted": 0, + "errors": [], + } + all_postings: list = [] + + try: + for board_token, status, payload in adapter.fetch_all_postings(): + if status == "ok": + provider_report["boards_succeeded"] += 1 + all_postings.extend(payload) + elif status == "empty": + # Empty boards are NOT errors — just nothing to add. + provider_report["boards_succeeded"] += 1 + else: # "error" + provider_report["boards_failed"] += 1 + provider_report["errors"].append( + {"board": board_token, "message": str(payload)[:200]} + ) + except Exception as exc: # noqa: BLE001 — adapter-level catastrophic failure + log_event( + LOGGER, + logging.WARNING, + "cached_jobs_refresh_provider_failed", + f"Provider {source_name} threw before any boards could be processed.", + provider=source_name, + error=f"{type(exc).__name__}: {exc}", + ) + provider_report["status"] = "error" + provider_report["errors"].append( + {"board": "", "message": f"{type(exc).__name__}: {exc}"} + ) + providers_report[source_name] = provider_report + continue + + # Upsert in chunks. The cached_jobs table has a GENERATED + # STORED `search_tsv` tsvector column that gets re-derived on + # every row insert plus index churn on every chunk; that work + # has to fit inside Supabase's default 60 s + # `statement_timeout` for the service_role REST path. + # + # Sized history: + # - 200 → intermittently failed after sustained writes + # - 100 → fine for greenhouse + lever + workday early on, + # but Ashby (heavier descriptions) was hitting the + # timeout consistently, dropping ~500 rows per refresh. + # We patched Ashby alone to 30 and kept the others at + # 100 in commit 18fde26. + # - As the table grew past 12 K rows, indexes/tsvector + # work scaled, and at 13 K rows greenhouse + lever + # started hitting the 60 s wall too at chunk_size=100, + # silently dropping ~100-200 rows per refresh. + # + # 30 across all sources is the conservative resolution. + # Trade-off: ~3-4× more HTTP roundtrips per refresh (an + # extra ~250 calls), but each one comfortably finishes + # inside the statement_timeout, so every row lands. + if all_postings: + chunk_size = 30 + for i in range(0, len(all_postings), chunk_size): + chunk = all_postings[i : i + chunk_size] + try: + upserted = cache.upsert_postings(source_name, chunk) + except Exception as exc: # noqa: BLE001 + # Surface the underlying message (AppError hides it + # in .details). Production debugging needs the real + # supabase error string, not just our wrapper name. + detail = ( + getattr(exc, "details", None) + or f"{type(exc).__name__}: {exc}" + ) + log_event( + LOGGER, + logging.WARNING, + "cached_jobs_refresh_upsert_failed", + f"Failed to upsert chunk for {source_name}: {detail}", + provider=source_name, + chunk_start=i, + chunk_size=len(chunk), + error=detail, + ) + provider_report["status"] = "partial" + provider_report["errors"].append( + { + "board": f"", + "message": detail, + } + ) + continue + provider_report["postings_upserted"] += upserted + + # Cleanup eligibility: a provider qualifies for cleanup ONLY if + # at least one board succeeded. If every single board failed + # (e.g., DNS down, provider outage), we skip cleanup so the + # cache survives the outage gracefully. + if provider_report["boards_succeeded"] > 0: + successfully_refreshed.append(source_name) + if provider_report["boards_failed"] > 0: + provider_report["status"] = "partial" + elif provider_report["boards_failed"] > 0: + # Every board failed — surface as 'error' so monitoring + # catches a provider outage instead of misreading the + # default 'ok' status. (Earlier bug: the only paths that + # set status away from 'ok' assumed boards_succeeded > 0, + # so an all-failed provider silently looked healthy.) + provider_report["status"] = "error" + + providers_report[source_name] = provider_report + + # Single cleanup pass across all successfully-refreshed providers. + # Tombstone-vs-delete decision is made by the store based on the + # saved_jobs table contents. + if successfully_refreshed: + try: + tombstoned, deleted = cache.cleanup_missing( + sources_refreshed=successfully_refreshed, + cutoff_iso=cutoff_iso, + ) + except Exception as exc: # noqa: BLE001 + log_event( + LOGGER, + logging.WARNING, + "cached_jobs_refresh_cleanup_failed", + "Cleanup failed; cache rows from previous runs may be stale.", + error=f"{type(exc).__name__}: {exc}", + ) + tombstoned, deleted = (0, 0) + + # Distribute the cleanup totals across the refreshed providers + # in proportion to who needed cleanup. We don't have per-source + # counts from cleanup_missing, so we report it under a synthetic + # "_cleanup" key per provider — good enough for an admin endpoint. + # (If you want exact per-provider counts, run cleanup once per + # source — slightly more expensive, less concurrent.) + for name in successfully_refreshed: + providers_report[name]["tombstoned"] = ( + tombstoned // len(successfully_refreshed) + ) + providers_report[name]["deleted"] = ( + deleted // len(successfully_refreshed) + ) + + finished_at = _utc_now_iso() + duration = round(time.perf_counter() - started_perf, 2) + total_active = cache.count_active() + + report = { + "started_at": started_at, + "finished_at": finished_at, + "duration_seconds": duration, + "providers": providers_report, + "total_active_after": total_active, + } + + log_event( + LOGGER, + logging.INFO, + "cached_jobs_refresh_completed", + f"Refreshed cached_jobs in {duration:.2f}s; {total_active} active rows.", + duration_seconds=duration, + total_active=total_active, + providers=list(providers_report.keys()), + ) + return report diff --git a/backend/services/job_search_service.py b/backend/services/job_search_service.py new file mode 100644 index 0000000..1872160 --- /dev/null +++ b/backend/services/job_search_service.py @@ -0,0 +1,195 @@ +from datetime import datetime, timezone + +from src.cached_jobs_store import CachedJobsStore +from src.job_sources.registry import build_default_job_sources +from src.schemas import JobPosting, JobResolutionResult, JobSearchQuery, JobSearchResult + + +def _row_to_job_posting(row: dict) -> JobPosting: + """Convert a cached_jobs row dict (as returned by Supabase) into a + JobPosting dataclass so the response model is unchanged. + + Column-to-attr remap: cached_jobs.job_id → JobPosting.id, + cached_jobs.description → JobPosting.description_text. Everything + else passes through 1:1. + """ + posted_at_value = row.get("posted_at") or "" + return JobPosting( + id=str(row.get("job_id", "") or ""), + source=str(row.get("source", "") or ""), + title=str(row.get("title", "") or ""), + company=str(row.get("company", "") or ""), + location=str(row.get("location", "") or ""), + employment_type=str(row.get("employment_type", "") or ""), + url=str(row.get("url", "") or ""), + summary=str(row.get("summary", "") or ""), + description_text=str(row.get("description", "") or ""), + posted_at=str(posted_at_value or ""), + scraped_at=str(row.get("last_seen_at", "") or ""), + metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {}, + ) + + +def _dedupe_key(posting) -> str: + normalized_url = str(getattr(posting, "url", "") or "").strip().lower() + if normalized_url: + return f"url:{normalized_url}" + + source = str(getattr(posting, "source", "") or "").strip().lower() + posting_id = str(getattr(posting, "id", "") or "").strip().lower() + if source and posting_id: + return f"id:{source}:{posting_id}" + + title = str(getattr(posting, "title", "") or "").strip().lower() + company = str(getattr(posting, "company", "") or "").strip().lower() + location = str(getattr(posting, "location", "") or "").strip().lower() + return f"title:{title}|company:{company}|location:{location}" + + +def _parse_posted_at(value: str): + raw_value = str(value or "").strip() + if not raw_value: + return None + try: + if raw_value.endswith("Z"): + raw_value = raw_value[:-1] + "+00:00" + return datetime.fromisoformat(raw_value) + except ValueError: + return None + + +def _posted_timestamp(value: str) -> float: + parsed = _parse_posted_at(value) + if parsed is None: + return 0.0 + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + + +class JobSearchService: + """Initial backend boundary for provider-backed job search.""" + + def __init__(self, sources=None, cache_store: CachedJobsStore | None = None): + self._sources = list(sources or build_default_job_sources()) + # Constructed lazily so unconfigured deployments (no service-role + # key) don't blow up at import time — only when search_cached + # is actually invoked. Tests can inject a fake store directly. + self._cache_store = cache_store + + def _get_cache_store(self) -> CachedJobsStore: + if self._cache_store is None: + self._cache_store = CachedJobsStore() + return self._cache_store + + def search_cached(self, query: JobSearchQuery) -> JobSearchResult: + """Cache-backed search. Default path for /jobs/search. + + Hits the cached_jobs Supabase table via Postgres FTS instead of + fanning out to every Greenhouse / Lever board. ~30ms vs ~1-3s + for the live path. Stays compatible with the existing + JobSearchResult shape so the response model is unchanged. + + Falls back to the live path automatically when the cache is + unconfigured (no SUPABASE_SERVICE_ROLE_KEY) — keeps local-dev + environments working without forcing every developer to wire + up the service role key. + """ + normalized_query = JobSearchQuery( + query=str(query.query or "").strip(), + location=str(query.location or "").strip(), + source_filters=list(query.source_filters or []), + remote_only=bool(query.remote_only), + posted_within_days=query.posted_within_days, + page_size=max(1, min(int(query.page_size or 20), 50)), + ) + + store = self._get_cache_store() + if not store.is_configured(): + # Graceful degradation — local dev or staging without the + # service-role key falls back to the live fan-out so the + # endpoint still returns results. + result = self.search(normalized_query) + result.source_status["cache"] = "not_configured" + return result + + try: + rows = store.search( + query=normalized_query.query, + location=normalized_query.location, + sources=list(normalized_query.source_filters) or None, + remote_only=normalized_query.remote_only, + posted_within_days=normalized_query.posted_within_days, + limit=normalized_query.page_size, + ) + except Exception as exc: # noqa: BLE001 — cache outage shouldn't kill search + # Fall through to the live path. The cache is a perf + # optimisation, not a correctness boundary. + result = self.search(normalized_query) + result.source_status["cache"] = f"error: {type(exc).__name__}" + return result + + postings = [_row_to_job_posting(row) for row in rows] + return JobSearchResult( + query=normalized_query, + results=postings, + total_results=len(postings), + source_status={"cache": "ok", "backend": "ready"}, + ) + + def search(self, query: JobSearchQuery) -> JobSearchResult: + normalized_query = JobSearchQuery( + query=str(query.query or "").strip(), + location=str(query.location or "").strip(), + source_filters=list(query.source_filters or []), + remote_only=bool(query.remote_only), + posted_within_days=query.posted_within_days, + page_size=max(1, min(int(query.page_size or 20), 50)), + ) + requested_sources = { + str(item).strip().lower() + for item in normalized_query.source_filters + if str(item).strip() + } + active_sources = [ + source for source in self._sources + if not requested_sources or source.source_name.lower() in requested_sources + ] + results = [] + source_status = {"backend": "ready"} + for source in active_sources: + response = source.search(normalized_query) + source_status[source.source_name] = response.status + source_status.update(response.source_details) + results.extend(response.results) + results.sort(key=lambda posting: _posted_timestamp(getattr(posting, "posted_at", "")), reverse=True) + deduped_results = [] + seen_keys = set() + for posting in results: + key = _dedupe_key(posting) + if key in seen_keys: + continue + seen_keys.add(key) + deduped_results.append(posting) + results = deduped_results[: normalized_query.page_size] + return JobSearchResult( + query=normalized_query, + results=results, + total_results=len(results), + source_status=source_status, + ) + + def resolve_url(self, url: str) -> JobResolutionResult: + normalized_url = str(url or "").strip() + for source in self._sources: + if source.can_resolve_url(normalized_url): + return source.resolve_url(normalized_url) + return JobResolutionResult( + source="unknown", + status="unsupported", + error_message="No configured provider can resolve that job URL.", + ) + + +def get_job_search_service() -> JobSearchService: + return JobSearchService() diff --git a/backend/services/resume_builder_persistence_service.py b/backend/services/resume_builder_persistence_service.py new file mode 100644 index 0000000..4e3bdee --- /dev/null +++ b/backend/services/resume_builder_persistence_service.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from backend.services.auth_session_service import resolve_authenticated_context +from backend.services.resume_builder_service import ( + export_resume_builder_session_payload, + has_resume_builder_session, + restore_resume_builder_session_payload, +) +from src.resume_builder_store import ResumeBuilderStore + + +def _resolve_store(*, access_token: str, refresh_token: str): + normalized_access = str(access_token or "").strip() + normalized_refresh = str(refresh_token or "").strip() + if not normalized_access or not normalized_refresh: + return None, None + + try: + context = resolve_authenticated_context( + access_token=normalized_access, + refresh_token=normalized_refresh, + ) + except Exception: + return None, None + + store = ResumeBuilderStore(context.auth_service) + if not store.is_configured(): + return None, None + return context, store + + +def load_latest_resume_builder_session(*, access_token: str, refresh_token: str): + context, store = _resolve_store( + access_token=access_token, + refresh_token=refresh_token, + ) + if context is None or store is None: + return { + "status": "missing", + "session": None, + } + + try: + record = store.load_latest_session( + access_token, + refresh_token, + context.app_user.id, + ) + except Exception: + return { + "status": "missing", + "session": None, + } + + if record is None or not record.session_payload_json: + return { + "status": "missing", + "session": None, + } + + try: + session = restore_resume_builder_session_payload(record.session_payload_json) + except Exception: + return { + "status": "missing", + "session": None, + } + + return { + "status": "available", + "session": session, + } + + +def hydrate_resume_builder_session_if_needed( + *, + access_token: str, + refresh_token: str, + session_id: str, +): + """Pull the user's persisted draft back into `_SESSIONS` on a cache miss. + + The single uvicorn worker holds resume-builder sessions in a process-local + dict, so a container restart mid-session leaves the user holding a + `session_id` that isn't in memory. The downstream service would then + raise `ValueError("Resume builder session not found.")` even though the + session is safely in Supabase. Pre-flight this helper from each mutating + route so the cache miss is silent and the user's draft is restored. + + Errors and `unconfigured` paths are swallowed: if hydration fails, the + downstream service falls through to its existing 400. + """ + normalized_id = str(session_id or "").strip() + if not normalized_id or has_resume_builder_session(normalized_id): + return + + context, store = _resolve_store( + access_token=access_token, + refresh_token=refresh_token, + ) + if context is None or store is None: + return + + try: + record = store.load_latest_session( + access_token, + refresh_token, + context.app_user.id, + ) + except Exception: + return + + if record is None or not record.session_payload_json: + return + + try: + restore_resume_builder_session_payload(record.session_payload_json) + except Exception: + return + + +def persist_resume_builder_session( + *, + access_token: str, + refresh_token: str, + session_id: str, +): + context, store = _resolve_store( + access_token=access_token, + refresh_token=refresh_token, + ) + if context is None or store is None: + return {"status": "skipped"} + + try: + session_payload_json = export_resume_builder_session_payload(session_id=session_id) + except Exception: + return {"status": "skipped"} + + try: + record = store.save_session( + access_token, + refresh_token, + { + "user_id": context.app_user.id, + "session_id": session_id, + "session_payload_json": session_payload_json, + }, + ) + except Exception: + return {"status": "skipped"} + + return { + "status": "saved", + # ISO timestamp for the row's expiry. Used by + # _attach_persistence_status to surface a 'refreshes through X' + # hint in the resume-builder UI. None when the store can't + # read it back (older callers); the UI handles missing + # gracefully. + "expires_at": getattr(record, "expires_at", "") or "", + } + + +def clear_resume_builder_session(*, access_token: str, refresh_token: str): + context, store = _resolve_store( + access_token=access_token, + refresh_token=refresh_token, + ) + if context is None or store is None: + return {"status": "skipped"} + + try: + store.delete_session(access_token, refresh_token, context.app_user.id) + except Exception: + return {"status": "skipped"} + return {"status": "cleared"} diff --git a/backend/services/resume_builder_service.py b/backend/services/resume_builder_service.py new file mode 100644 index 0000000..417630f --- /dev/null +++ b/backend/services/resume_builder_service.py @@ -0,0 +1,2220 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import re +from dataclasses import asdict, dataclass, field +from typing import Any, Literal +from uuid import uuid4 + +from backend import quota +from backend.services.auth_session_service import resolve_authenticated_context +from backend.tiers import resolve_user_tier +from src.config import get_openai_max_completion_tokens_for_task +from src.errors import AgentExecutionError +from src.exporters import export_docx_bytes, export_pdf_bytes +from src.logging_utils import get_logger, log_event +from src.prompts import build_resume_builder_prompt, build_resume_builder_structuring_prompt +from src.resume_builder import build_tailored_resume_artifact +from src.schemas import ( + CandidateProfile, + EducationEntry, + FitAnalysis, + JobDescription, + JobRequirements, + ProjectEntry, + ResumeDocument, + TailoredResumeDraft, + WorkExperience, +) +from src.schemas_llm_outputs import ResumeBuilderStructuringOutput +from src.utils import dedupe_strings, markdown_to_text, slugify_text + + +LOGGER = get_logger(__name__) + + +EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE) +PHONE_PATTERN = re.compile(r"(?:\+\d{1,3}[\s\-]?)?(?:\(?\d{2,5}\)?[\s\-]?){2,4}\d{2,4}") +URL_PATTERN = re.compile(r"(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[^\s,;]+)?", re.IGNORECASE) + +RESUME_BUILDER_STEPS: list[tuple[str, str]] = [ + ( + "basics", + "Tell me your full name, location, email, phone number, and any links you want on the resume.", + ), + ( + "role", + "What kind of role are you targeting, and how would you describe your background in 2-3 lines?", + ), + ( + "experience", + "Describe your most relevant experience. Include your role, company, dates, and 2-4 impact points if you can.", + ), + ( + "education", + "Share your education details and any certifications you want included.", + ), + ( + "skills", + "List the tools, technologies, and strengths you want highlighted on the resume.", + ), +] + + +@dataclass +class ResumeBuilderDraft: + full_name: str = "" + location: str = "" + contact_lines: list[str] = field(default_factory=list) + target_role: str = "" + professional_summary: str = "" + experience_notes: str = "" + education_notes: str = "" + skills: list[str] = field(default_factory=list) + certifications: list[str] = field(default_factory=list) + # Projects: free-form prose like experience_notes (the LLM intake + # captures verbatim, the structuring pass turns it into ProjectEntry + # objects). Optional — only asked when the user has a tech-heavy + # background or mentions side projects. + projects_notes: str = "" + # Publications: list of citation strings, like certifications. + # Optional — only relevant for academics / researchers. + publications: list[str] = field(default_factory=list) + + +@dataclass +class ResumeBuilderSession: + session_id: str + current_step: str = "basics" + status: Literal["collecting", "reviewing", "ready"] = "collecting" + draft: ResumeBuilderDraft = field(default_factory=ResumeBuilderDraft) + generated_resume_markdown: str = "" + generated_resume_plain_text: str = "" + # Conversational LLM intake stores user/assistant turn pairs here so + # subsequent turns have narrative continuity (backtracking, "as I + # said earlier" references, etc.). Each entry is + # `{"role": "user" | "assistant", "content": str}`. Empty list means + # the regex / step-machine flow is in use. + conversation_history: list[dict] = field(default_factory=list) + # Cache for the LLM structuring pass. The signature is a SHA256 of + # the inputs the structuring prompt sees; if the user edits any of + # those inputs the hash changes and we re-run the LLM. Without this + # cache every export at a different theme (or a re-download for a + # different format) re-burns a structuring call AND the LLM may + # rephrase bullets between calls — re-downloads would silently + # produce different wording, which felt off in QA. + structuring_signature: str = "" + structured_experience_payload: list[dict] = field(default_factory=list) + structured_education_payload: list[dict] = field(default_factory=list) + structured_projects_payload: list[dict] = field(default_factory=list) + structured_skill_categories: dict = field(default_factory=dict) + structured_professional_summary: str = "" + + +_SESSIONS: dict[str, ResumeBuilderSession] = {} + + +def _step_index(step: str) -> int: + for index, (key, _) in enumerate(RESUME_BUILDER_STEPS): + if key == step: + return index + return 0 + + +def _current_prompt(step: str) -> str: + for key, prompt in RESUME_BUILDER_STEPS: + if key == step: + return prompt + return RESUME_BUILDER_STEPS[0][1] + + +def _normalize_lines(message: str) -> list[str]: + return [line.strip(" -•\t") for line in str(message or "").splitlines() if line.strip()] + + +def _split_tokens(message: str) -> list[str]: + raw_parts = re.split(r"[\n,;|]+", str(message or "")) + return [part.strip() for part in raw_parts if part.strip()] + + +def _extract_contact_lines(message: str) -> list[str]: + parts = _split_tokens(message) + contacts: list[str] = [] + for part in parts: + if EMAIL_PATTERN.search(part) or PHONE_PATTERN.search(part) or URL_PATTERN.search(part): + contacts.append(part) + return dedupe_strings(contacts) + + +def _looks_like_location(value: str) -> bool: + lowered = value.lower() + return "," in value or "remote" in lowered or len(value.split()) <= 4 + + +_LOCATION_PREAMBLE_PATTERN = re.compile( + r"\b(?:based in|from|located in|living in|currently in|in)\b\s+", + re.IGNORECASE, +) +_NAME_PREAMBLE_PATTERN = re.compile( + r"^(?:i\s+am|i'?m|my\s+name\s+is|name[:]?)\s*", + re.IGNORECASE, +) + + +def _looks_like_personal_name(value: str) -> bool: + """Heuristic: looks like a person's name (1-5 short words, mostly letters). + + Unicode-aware: accepts any letter the user's keyboard produces, + including accented Latin (François, Müller), Cyrillic, CJK, etc. + Rejects digits, underscores, and structural symbols (`@`, `/`, etc.) + so emails/urls/role labels can't be misclassified as names. + """ + cleaned = value.strip(" ,.;-").strip() + if not cleaned or "@" in cleaned or "/" in cleaned or "http" in cleaned.lower(): + return False + words = cleaned.split() + if not (1 <= len(words) <= 5): + return False + for word in words: + if not word or not word[0].isalpha(): + return False + # Allow inner connectors (apostrophe, hyphen, period — for + # names like O'Brien, Smith-Jones, St. John) plus any letter + # in any script. Reject digits and underscores. + for ch in word[1:]: + if not (ch.isalpha() or ch in "'-."): + return False + return True + + +def _apply_basics(session: ResumeBuilderSession, message: str): + """Pull out name, location, and contact lines from a free-form answer. + + Users typically reply on one line ("Leander Antony A, based in Chennai, + India. Email: …, phone: …, GitHub: …") so the prior implementation + that only split on newlines fell back to empty name/location every + time. This pass splits the message on commas + sentence boundaries, + classifies each chunk, and threads the leftovers into name/location + detection. + """ + text = str(message or "").strip() + if not text: + return + + # Coarse split: commas, semicolons, pipes, newlines, AND sentence + # boundaries. The contact extractor below only cares about chunks + # that hold a contact pattern; the rest are name/location candidates. + coarse_parts = [ + part.strip() + for part in re.split(r"[\n,;|]+|(?<=[.!?])\s+", text) + if part and part.strip() + ] + if not coarse_parts: + return + + contact_chunks: list[str] = [] + leftover_chunks: list[str] = [] + + for part in coarse_parts: + is_contact = bool( + EMAIL_PATTERN.search(part) + or PHONE_PATTERN.search(part) + or URL_PATTERN.search(part) + ) + if is_contact: + # Extract just the contact-bearing token out of the chunk — + # avoids storing prose like "phone: +91 …" with the + # leading label, which doesn't belong on a resume header. + contact_chunks.extend(_extract_contact_lines(part) or [part]) + else: + leftover_chunks.append(part.rstrip(".").strip()) + + session.draft.contact_lines = dedupe_strings( + session.draft.contact_lines + contact_chunks + ) + + if not leftover_chunks: + return + + # Strip "I'm / my name is" preambles before classifying. + cleaned_chunks: list[str] = [] + for chunk in leftover_chunks: + cleaned = _NAME_PREAMBLE_PATTERN.sub("", chunk).strip() + # Strip "based in X" → "X" so the location chunk is just the + # place; the preamble itself is noise. + if _LOCATION_PREAMBLE_PATTERN.search(cleaned): + cleaned = _LOCATION_PREAMBLE_PATTERN.sub("", cleaned, count=1).strip() + if cleaned: + cleaned_chunks.append(cleaned) + + if not cleaned_chunks: + return + + # First chunk that looks like a personal name → full_name. + name_index: int | None = None + for index, chunk in enumerate(cleaned_chunks): + if _looks_like_personal_name(chunk): + session.draft.full_name = chunk + name_index = index + break + + # Location: combine adjacent chunks that look like place fragments + # ("Chennai" + "India" → "Chennai, India"). Skip the name chunk. + location_parts: list[str] = [] + for index, chunk in enumerate(cleaned_chunks): + if index == name_index: + continue + if _looks_like_location(chunk): + location_parts.append(chunk) + if location_parts: + session.draft.location = ", ".join(location_parts[:2]) + + +_ROLE_PREAMBLE_PATTERN = re.compile( + r"^\s*(?:i'?m\s+)?(?:currently\s+|primarily\s+|mostly\s+)?" + r"(?:targeting|looking\s+for|aiming\s+for|seeking|interested\s+in)\s+", + re.IGNORECASE, +) +_ROLE_SUFFIX_PATTERN = re.compile(r"\s+roles?\s*$", re.IGNORECASE) + + +def _apply_role(session: ResumeBuilderSession, message: str): + """Split the role answer into a SHORT title + a free-form summary. + + Users routinely answer with a paragraph ("Targeting Senior ML + Engineer / Applied AI roles. Independent ML engineer with 4 years + building production AI systems including …"). The prior version + stuffed the whole paragraph into target_role AND professional_summary, + which then renders as a cramped multi-line role title in the resume + header. Now we split on the first sentence boundary, strip + "Targeting / looking for" preambles, and cap the title length. + + Newline-first: when the user answers with the title on line 1 and + background on line 2 (a very common pattern, even without + sentence-ending punctuation on line 1), prefer the newline split. + Fall through to sentence-boundary splitting only when the message + is single-line. + """ + text = str(message or "").strip() + if not text: + return + + title_chunk: str = "" + summary_chunk: str = "" + if "\n" in text: + leading, _, remainder = text.partition("\n") + leading_clean = leading.strip().rstrip(".!?,;: ") + if leading_clean and len(leading_clean) <= 80: + title_chunk = leading_clean + summary_chunk = remainder.strip() + if not title_chunk: + # Single-line answer (or leading line was too long to be a + # title): fall back to sentence-boundary splitting so a + # paragraph-style answer still extracts the lead clause. + sentence_split = re.split(r"(?<=[.!?])\s+", text, maxsplit=1) + title_chunk = sentence_split[0].strip().rstrip(".!?,;: ") + summary_chunk = sentence_split[1].strip() if len(sentence_split) > 1 else "" + + # Strip "Targeting" / "Looking for" / trailing "roles" so the stored + # value is the role TITLE itself, not the user's framing of it. + title_chunk = _ROLE_PREAMBLE_PATTERN.sub("", title_chunk).strip() + title_chunk = _ROLE_SUFFIX_PATTERN.sub("", title_chunk).strip() + + # Cap the title at 80 chars so we don't render paragraphs in the + # resume header. Trim on the last word boundary when over budget. + if len(title_chunk) > 80: + truncated = title_chunk[:80] + last_space = truncated.rfind(" ") + if last_space > 40: + truncated = truncated[:last_space] + title_chunk = f"{truncated}…" + + session.draft.target_role = title_chunk + + # Summary defaults to the post-title sentences. If the user wrote + # only a single sentence (no period), fall back to the whole text + # so the summary slot isn't empty and the resume preview reads + # naturally. + session.draft.professional_summary = summary_chunk or text + + +def _apply_experience(session: ResumeBuilderSession, message: str): + session.draft.experience_notes = str(message or "").strip() + + +def _apply_education(session: ResumeBuilderSession, message: str): + raw_lines = _normalize_lines(message) + if not raw_lines: + return + certifications = [ + line + for line in raw_lines + if re.search(r"certif|certificate|credential|specialization|specialisation", line, re.IGNORECASE) + ] + session.draft.certifications = dedupe_strings(session.draft.certifications + certifications) + education_lines = [line for line in raw_lines if line not in certifications] + session.draft.education_notes = "\n".join(education_lines).strip() + + +def _apply_skills(session: ResumeBuilderSession, message: str): + skills = [ + token + for token in _split_tokens(message) + if len(token) > 1 + ] + session.draft.skills = dedupe_strings(skills) + + +def _build_next_message(previous_step: str, next_step: str | None) -> str: + acknowledgements = { + "basics": "Got it. I’ve captured your contact details.", + "role": "Nice. I’ve got the role direction and your summary.", + "experience": "Great. I’ve saved your experience notes.", + "education": "Perfect. I’ve added your education details.", + "skills": "Nice. I’ve captured the skills you want highlighted.", + } + if not next_step: + return ( + f"{acknowledgements.get(previous_step, 'Saved.')}" + " Everything is collected. Review the draft and generate your base resume when you’re ready." + ) + return f"{acknowledgements.get(previous_step, 'Saved.')} {_current_prompt(next_step)}" + + +def _apply_draft_updates(session: ResumeBuilderSession, updates: dict): + if "full_name" in updates: + session.draft.full_name = str(updates.get("full_name", "") or "").strip() + if "location" in updates: + session.draft.location = str(updates.get("location", "") or "").strip() + if "contact_lines" in updates: + contact_lines = updates.get("contact_lines", []) + if not isinstance(contact_lines, list): + contact_lines = [] + session.draft.contact_lines = dedupe_strings( + [str(item).strip() for item in contact_lines if str(item).strip()] + ) + if "target_role" in updates: + session.draft.target_role = str(updates.get("target_role", "") or "").strip() + if "professional_summary" in updates: + session.draft.professional_summary = str( + updates.get("professional_summary", "") or "" + ).strip() + if "experience_notes" in updates: + session.draft.experience_notes = str( + updates.get("experience_notes", "") or "" + ).strip() + if "education_notes" in updates: + session.draft.education_notes = str( + updates.get("education_notes", "") or "" + ).strip() + if "skills" in updates: + skills = updates.get("skills", []) + if not isinstance(skills, list): + skills = [] + session.draft.skills = dedupe_strings( + [str(item).strip() for item in skills if str(item).strip()] + ) + if "certifications" in updates: + certifications = updates.get("certifications", []) + if not isinstance(certifications, list): + certifications = [] + session.draft.certifications = dedupe_strings( + [str(item).strip() for item in certifications if str(item).strip()] + ) + if "projects_notes" in updates: + session.draft.projects_notes = str(updates.get("projects_notes", "") or "").strip() + if "publications" in updates: + publications = updates.get("publications", []) + if not isinstance(publications, list): + publications = [] + session.draft.publications = dedupe_strings( + [str(item).strip() for item in publications if str(item).strip()] + ) + + +# Patterns shared by the experience/education parsers below. Defined at +# module scope so they're compiled once. +# +# Date tokens we accept inside headlines: 4-digit years, "Present", +# "Current", "Now", and month-name + year (Jan 2023, March 2024, etc.). +_DATE_TOKEN_RE = ( + r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" + r"[a-z]*\.?\s*\d{4}|\d{4}|Present|present|Current|current|Now|now)" +) +_DATE_RANGE_PATTERN = re.compile( + rf"\b({_DATE_TOKEN_RE})\s*[\-–—]\s*({_DATE_TOKEN_RE})\b" +) +_PARENTHETICAL_DATES_PATTERN = re.compile( + rf"\(\s*([^)]*{_DATE_TOKEN_RE}[^)]*?)\s*\)", re.IGNORECASE +) +_SINGLE_DATE_PATTERN = re.compile(rf"\b({_DATE_TOKEN_RE})\b") +_LEADING_FROM_PATTERN = re.compile(r"^\s*(?:from|since|in)\s+", re.IGNORECASE) +_TRAILING_FROM_PATTERN = re.compile(r"\s+(?:from|since|in)\s*$", re.IGNORECASE) +# Role transition markers users say when squashing two roles onto one line: +# "X at A 2020-Present, prior at B 2017-2020" / "Y at A then earlier at B" +_ROLE_TRANSITION_PATTERN = re.compile( + r"\s*[,;\.]?\s*\b" + r"(?:prior(?:ly)?|previously|previous(?:\s+role|\s+job|\s+position)?" + r"|before(?:\s+that)?|earlier|formerly|then(?:\s+at)?)\b\s+", + re.IGNORECASE, +) +# Common degree abbreviations + a couple of common spellings, used to detect +# multiple education entries on one line. +_DEGREE_PATTERN = re.compile( + r"\b(?:B\.?S\.?c?|B\.?A\.?|B\.?Tech|B\.?E\.?|B\.?Eng|B\.?Sc" + r"|M\.?S\.?c?|M\.?A\.?|M\.?Tech|M\.?B\.?A\.?|M\.?E\.?|M\.?Eng" + r"|Ph\.?D|Doctorate|Diploma|Associate|Bachelor[s]?|Master[s]?)\b", + re.IGNORECASE, +) +# Institution markers — words that, when present, almost certainly mark +# the institution part of an education chunk. Mirrors +# `INSTITUTION_KEYWORDS` in `src/services/profile_service.py`. +_INSTITUTION_KEYWORDS = ( + "university", + "institute", + "college", + "school", + "academy", + "polytechnic", + "iiit", + "iit", + "nit", +) + + +def _split_date_range_parts(date_text: str) -> tuple[str, str]: + """Split "2020 - 2024" into ("2020", "2024"); single dates → (date, "").""" + normalized = (date_text or "").strip() + if not normalized: + return "", "" + parts = re.split(r"\s*[\-–—]\s*", normalized, maxsplit=1) + if len(parts) == 2: + return parts[0].strip(), parts[1].strip() + return normalized, "" + + +def _split_into_sentences(text: str) -> list[str]: + """Split free-form prose on newlines AND sentence boundaries. + + Users may type the whole experience block as one paragraph + ("Engineer at A 2020-Present. Built X. Reduced Y."). This expands + such input into the sentences we then group into role blocks. + """ + if not text: + return [] + raw = re.split(r"(?<=[\.!?])\s+|\n+", str(text)) + return [chunk.strip(" \t-•") for chunk in raw if chunk.strip(" \t-•")] + + +def _looks_like_role_headline(line: str) -> bool: + """Heuristic: does this sentence start a new role block? + + A headline typically contains " at " (Engineer at Acme), a 4-digit + year (2024), a parenthetical date span, or pipe-separated columns. + """ + if not line: + return False + stripped = line.strip() + if stripped.startswith(("- ", "* ", "• ", "→ ")): + return False + if re.search(r"\bat\b", stripped, re.IGNORECASE): + return True + if re.search(r"\b(19|20)\d{2}\b", stripped): + return True + if "|" in stripped: + return True + return False + + +def _split_headline_on_transitions(headline: str) -> list[str]: + """Break "X at A 2020-Present, prior at B 2017-2020" into two headlines. + + Returns the original headline (single-element list) if no transition + markers are present. Splitting happens BEFORE the transition word so + each fragment retains its own role context. + """ + parts = _ROLE_TRANSITION_PATTERN.split(headline) + cleaned = [part.strip(" ,;.-") for part in parts if part.strip(" ,;.-")] + return cleaned or [headline] + + +def _split_into_role_blocks(notes: str) -> list[list[str]]: + """Group sentences into role blocks of [headline, *bullets]. + + Walks the sentences once: a sentence that looks like a headline + starts a new block; non-headline sentences attach as bullets to the + current block (or seed the first block if no headline came before). + If the entire input collapsed to a single block, we try to detect + multiple roles within the headline itself. + """ + sentences = _split_into_sentences(notes) + if not sentences: + return [] + + blocks: list[list[str]] = [] + current: list[str] = [] + for sentence in sentences: + if _looks_like_role_headline(sentence): + if current: + blocks.append(current) + current = [sentence] + else: + if current: + current.append(sentence) + else: + # No headline yet — first sentence becomes the block's + # headline so we don't lose content. + current = [sentence] + if current: + blocks.append(current) + + if len(blocks) == 1 and blocks[0]: + headline = blocks[0][0] + bullets = blocks[0][1:] + sub_headlines = _split_headline_on_transitions(headline) + if len(sub_headlines) > 1: + # First sub-role keeps the bullets that followed the original + # headline; subsequent sub-roles start with no bullets since + # we don't know which ones belonged to which role. + blocks = [[sub_headlines[0]] + bullets] + for sub in sub_headlines[1:]: + blocks.append([sub]) + + return blocks + + +def _extract_headline_dates(headline: str) -> tuple[str, str, str]: + """Pull dates out of a role headline. + + Returns (cleaned_headline_without_dates, start, end). Dates can be: + parenthetical ("(Jan 2023 - Present)"), an explicit range + ("2020-2024"), or a single year ("2024"). + """ + cleaned = headline + start = "" + end = "" + + paren = _PARENTHETICAL_DATES_PATTERN.search(cleaned) + if paren: + start, end = _split_date_range_parts(paren.group(1).strip()) + cleaned = (cleaned[: paren.start()] + cleaned[paren.end():]).strip() + + if not start: + rng = _DATE_RANGE_PATTERN.search(cleaned) + if rng: + start = rng.group(1).strip() + end = rng.group(2).strip() + cleaned = (cleaned[: rng.start()] + cleaned[rng.end():]).strip() + + if not start: + single = _SINGLE_DATE_PATTERN.search(cleaned) + if single: + start = single.group(1).strip() + cleaned = (cleaned[: single.start()] + cleaned[single.end():]).strip() + + cleaned = _LEADING_FROM_PATTERN.sub("", cleaned).strip() + cleaned = _TRAILING_FROM_PATTERN.sub("", cleaned).strip() + # Date extraction can leave punctuation residue ("Example Labs ." after + # we lift "(Jan 2023 - Present)" out of "Example Labs (Jan 2023 - Present)."). + # Collapse those leftovers + any double whitespace before returning. + cleaned = re.sub(r"\s+([,;.\-])", r"\1", cleaned) + cleaned = re.sub(r"\s{2,}", " ", cleaned) + cleaned = cleaned.strip(" ,;.-") + return cleaned, start, end + + +def _parse_experience_headline(headline: str) -> tuple[str, str, str, str]: + """Extract (title, organization, start, end) from a role headline.""" + cleaned, start, end = _extract_headline_dates(headline) + + title = cleaned + organization = "" + + # Match " at " with required surrounding whitespace OR a leading "at " + # (e.g. transition-split sub-headlines like "at FinStart" — the leading + # "at" has no whitespace before it after we trimmed the chunk). + leading_at = re.match(r"^at\s+(.*)", cleaned, flags=re.IGNORECASE) + if leading_at: + title = "" + organization = leading_at.group(1).strip(" ,;-") + elif re.search(r"\s+at\s+", cleaned, re.IGNORECASE): + parts = re.split(r"\s+at\s+", cleaned, maxsplit=1, flags=re.IGNORECASE) + if len(parts) == 2: + title = parts[0].strip(" ,;-") + organization = parts[1].strip(" ,;-") + elif "|" in cleaned: + parts = [part.strip() for part in cleaned.split("|") if part.strip()] + if parts: + title = parts[0] + if len(parts) > 1: + organization = parts[1] + if len(parts) > 2 and not start: + start, end = _split_date_range_parts(parts[2]) + + if not title and organization: + # Headline was something like "at FinStart 2017-2020" — keep the + # org but flag a fallback title so the renderer doesn't drop the + # row. + title = "Relevant Experience" + return title or "Relevant Experience", organization, start, end + + +def _build_experience_entries(notes: str) -> list[WorkExperience]: + blocks = _split_into_role_blocks(notes) + if not blocks: + return [] + + entries: list[WorkExperience] = [] + for block in blocks: + if not block: + continue + headline = block[0] + bullets = [bullet for bullet in block[1:] if bullet] + title, organization, start, end = _parse_experience_headline(headline) + # description holds ONLY the explicit bullet sentences. Stuffing + # the headline back into description (the previous behaviour) + # caused it to render as both the meta line *and* a duplicated + # bullet row in the resume. + description = "\n".join(bullets).strip() + entries.append( + WorkExperience( + title=title, + organization=organization, + description=description, + start=start or None, + end=end or None, + ) + ) + return entries + + +def _split_education_into_chunks(notes: str) -> list[str]: + """Split education notes so each chunk is one degree. + + Newlines are the primary boundary. If a single line names multiple + degrees ("MS Computer Science Stanford 2017, BTech CS IIT Madras + 2015"), we split on commas and keep the chunks that carry their own + degree pattern or year. + """ + lines = _normalize_lines(notes) + if not lines: + return [] + + chunks: list[str] = [] + for line in lines: + if len(_DEGREE_PATTERN.findall(line)) <= 1: + chunks.append(line) + continue + # Multi-degree line — split on commas / semicolons / sentence breaks. + parts = [ + part.strip(" ,;.-") + for part in re.split(r"\s*[,;]\s*|\.\s+", line) + if part.strip(" ,;.-") + ] + for part in parts: + looks_like_entry = bool( + _DEGREE_PATTERN.search(part) + or re.search(r"\b(19|20)\d{2}\b", part) + ) + if looks_like_entry: + chunks.append(part) + elif chunks: + # Continuation fragment ("Magna Cum Laude") – stick it onto + # the previous chunk so we don't drop user-typed context. + chunks[-1] = (chunks[-1] + ", " + part).strip(", ") + else: + chunks.append(part) + return chunks + + +def _split_education_trailing_institution(text: str) -> tuple[str, str]: + """Split a "field-of-study + institution" trailing fragment. + + Returns (field_of_study, institution). Tries three strategies in + order: + 1. " from " connector — "Computer Science from Stanford". + 2. Institution keyword (university, institute, iit, ...) — finds + the token that carries the keyword and keeps the proper-noun + run that surrounds it ("CS IIT Madras" → "IIT Madras"). + 3. Fallback: the LAST single word is treated as the institution + (handles bare names like "Stanford", "Harvard", "MIT"). + """ + cleaned = text.strip(" ,;-") + if not cleaned: + return "", "" + + from_match = re.search(r"\s+from\s+", cleaned, flags=re.IGNORECASE) + if from_match: + field = cleaned[: from_match.start()].strip(" ,;-") + institution = cleaned[from_match.end():].strip(" ,;-") + return field, institution + + lowered = cleaned.lower() + for keyword in _INSTITUTION_KEYWORDS: + # `\b` keeps "iit" from matching inside "circuit"; still picks up + # "IIT Madras" via the prefix variant. + match = re.search(rf"\b{re.escape(keyword)}\b", lowered) + if not match: + continue + keyword_start = match.start() + keyword_end = match.end() + # Walk backwards through capitalized words to find the institution's + # leading edge — but only ONE word back, to avoid swallowing + # multi-word fields of study ("Computer Science Stanford + # University" → institution must be "Stanford University", not + # "Computer Science Stanford University"). Short all-caps tokens + # (CS, MS, BS, BA) are degree-field abbreviations, never an + # institution prefix. + prefix = cleaned[:keyword_start].rstrip() + institution_start = keyword_start + if prefix: + tokens = prefix.split() + if tokens: + last = tokens[-1] + if ( + last + and last[0].isupper() + and not (last.isupper() and len(last) <= 3) + ): + institution_start = cleaned.rfind(last, 0, keyword_start) + if institution_start < 0: + institution_start = keyword_start + # Walk forwards: institutions often have a trailing proper-noun + # qualifier ("IIT Madras", "University of Toronto", "Institute of + # Science"). + suffix = cleaned[keyword_end:] + institution_end = keyword_end + if suffix: + stripped = suffix.lstrip() + offset = len(suffix) - len(stripped) + tokens = stripped.split() + extra: list[str] = [] + connectors = {"of", "for", "and", "the"} + for token in tokens: + if token.lower() in connectors and extra: + extra.append(token) + continue + if token and (token[0].isupper() or token.lower() in connectors): + extra.append(token) + else: + break + if extra: + joined = " ".join(extra) + institution_end = ( + keyword_end + offset + suffix.lstrip().find(joined) + len(joined) + ) + institution = cleaned[institution_start:institution_end].strip(" ,;-") + field = ( + (cleaned[:institution_start] + " " + cleaned[institution_end:]) + .strip(" ,;-") + ) + return field, institution + + # Fallback: bare institution name (no keyword, no "from"). Last token + # is almost always the institution ("MS Computer Science Stanford"). + tokens = cleaned.split() + if len(tokens) >= 2: + return " ".join(tokens[:-1]), tokens[-1] + return "", cleaned + + +def _parse_education_chunk(chunk: str) -> tuple[str, str, str, str]: + """Extract (institution, degree, start, end) from one education chunk.""" + cleaned, start, end = _extract_headline_dates(chunk) + + institution = cleaned + degree = "" + deg_match = _DEGREE_PATTERN.search(cleaned) + if deg_match: + # Strip ".," etc. on both sides — `\b` only matches at the END of + # "B.E." after the "E", so the closing period leaks into `after` + # ("B.E . Computer Science") unless we explicitly strip it here. + before = cleaned[: deg_match.start()].strip(" ,;.-") + after = cleaned[deg_match.end():].strip(" ,;.-") + deg_token = deg_match.group(0).strip() + if before: + # "Stanford MS Computer Science" → institution=Stanford, + # degree=MS Computer Science. + institution = before + degree = (deg_token + (" " + after if after else "")).strip() + elif after: + # Degree comes first, institution is the trailing fragment. + field, institution_chunk = _split_education_trailing_institution(after) + if institution_chunk: + institution = institution_chunk + degree = (deg_token + (" " + field if field else "")).strip() + else: + institution = after + degree = deg_token + else: + institution = "" + degree = deg_token + elif "|" in cleaned: + parts = [part.strip() for part in cleaned.split("|") if part.strip()] + institution = parts[0] if parts else "" + if len(parts) > 1: + degree = parts[1] + + return institution.strip(), degree.strip(), start, end + + +def _build_education_entries(notes: str) -> list[EducationEntry]: + chunks = _split_education_into_chunks(notes) + if not chunks: + return [] + + entries: list[EducationEntry] = [] + for chunk in chunks: + institution, degree, start, end = _parse_education_chunk(chunk) + if not institution and not degree: + continue + entries.append( + EducationEntry( + institution=institution, + degree=degree, + start=start, + end=end, + ) + ) + return entries + + +def _coerce_str_value(value) -> str: + """LLM payloads occasionally hand us None or numerics — normalize to str.""" + if value is None: + return "" + return str(value).strip() + + +def _coerce_bullet_list(value) -> list[str]: + if not isinstance(value, list): + return [] + cleaned: list[str] = [] + for item in value: + text = _coerce_str_value(item) + if text: + cleaned.append(text) + return cleaned + + +def _build_experience_entry_from_llm(item: dict) -> WorkExperience | None: + """Convert one LLM-emitted role dict into a WorkExperience. + + Returns None for entries that are too sparse to render (no title and + no organization). The structured renderer in `src/resume_builder.py` + later splits the description (newline-joined bullets) back into a + bullet list, mirroring the behaviour the regex parser produces. + """ + if not isinstance(item, dict): + return None + title = _coerce_str_value(item.get("title")) + organization = _coerce_str_value(item.get("organization")) + if not title and not organization: + return None + bullets = _coerce_bullet_list(item.get("bullets")) + description = "\n".join(bullets).strip() + return WorkExperience( + title=title or "Relevant Experience", + organization=organization, + location=_coerce_str_value(item.get("location")), + description=description, + start=_coerce_str_value(item.get("start")) or None, + end=_coerce_str_value(item.get("end")) or None, + ) + + +def _build_education_entry_from_llm(item: dict) -> EducationEntry | None: + """Convert one LLM-emitted degree dict into an EducationEntry.""" + if not isinstance(item, dict): + return None + institution = _coerce_str_value(item.get("institution")) + degree = _coerce_str_value(item.get("degree")) + if not institution and not degree: + return None + field = _coerce_str_value(item.get("field_of_study")) + return EducationEntry( + institution=institution, + degree=degree, + field_of_study=field, + start=_coerce_str_value(item.get("start")), + end=_coerce_str_value(item.get("end")), + ) + + +def _build_project_entry_from_llm(item: dict) -> ProjectEntry | None: + """Convert one LLM-emitted project dict into a ProjectEntry.""" + if not isinstance(item, dict): + return None + name = _coerce_str_value(item.get("name")) + if not name: + return None + bullets = _coerce_bullet_list(item.get("bullets")) + technologies = _coerce_bullet_list(item.get("technologies")) + return ProjectEntry( + name=name, + description=_coerce_str_value(item.get("description")), + bullets=bullets, + technologies=technologies, + start=_coerce_str_value(item.get("start")), + end=_coerce_str_value(item.get("end")), + link=_coerce_str_value(item.get("link")), + ) + + +# URL detector for project links — covers github.com, vercel.app, .xyz, etc. +_PROJECT_LINK_PATTERN = re.compile( + r"(?:https?://)?(?:[\w-]+\.)+[a-z]{2,}(?:/[^\s,;]*)?", + re.IGNORECASE, +) + + +def _build_project_entries(notes: str) -> list[ProjectEntry]: + """Regex fallback: split projects prose into one ProjectEntry per + project. The LLM does this much better, but if the LLM is + unavailable we still want SOME structure — at minimum one entry per + bullet block separated by blank lines or sentence boundaries. + + Heuristic: each block becomes one project. The first line of a block + is the project name (with link extracted if present); remaining + lines become bullets. + """ + if not notes or not notes.strip(): + return [] + + # Split into blocks separated by double newlines or "Project: " markers. + # Falls back to splitting on single newlines if no double newlines. + blocks: list[str] = [] + if "\n\n" in notes: + blocks = [b.strip() for b in notes.split("\n\n") if b.strip()] + else: + # Try one-line-per-project; if there's only one line, use it as one project. + lines = [line.strip() for line in notes.splitlines() if line.strip()] + # Group consecutive bullet lines (start with '-') under the prior name line. + current: list[str] = [] + for line in lines: + if line.startswith(("- ", "* ", "• ")): + if current: + current.append(line.lstrip("-*• ").strip()) + else: + current = [line.lstrip("-*• ").strip()] + else: + if current: + blocks.append("\n".join(current)) + current = [line] + if current: + blocks.append("\n".join(current)) + + entries: list[ProjectEntry] = [] + for block in blocks: + block_lines = [line.strip() for line in block.splitlines() if line.strip()] + if not block_lines: + continue + headline = block_lines[0] + bullets = block_lines[1:] + # Extract link from headline if present. + link = "" + link_match = _PROJECT_LINK_PATTERN.search(headline) + if link_match: + link = link_match.group(0) + headline = ( + headline[: link_match.start()] + headline[link_match.end():] + ).strip(" -|,;") + name = headline or "Project" + entries.append( + ProjectEntry( + name=name, + bullets=[b.lstrip("-*• ").strip() for b in bullets if b.strip()], + link=link, + ) + ) + return entries + + +def _structuring_signature(draft: ResumeBuilderDraft) -> str: + """Stable hash of the inputs the structuring prompt sees. + + When this signature matches the one we cached on the session the + LLM call is a no-op — we rebuild the entries from the cached + payload. Any change to experience_notes / education_notes / draft + context the prompt feeds the model invalidates the cache by + yielding a different hash. + + Uses SHA256 (not for cryptographic strength — just for collision + resistance over the input space we expect: a few KB of text). + """ + payload = json.dumps( + { + "experience_notes": draft.experience_notes or "", + "education_notes": draft.education_notes or "", + "projects_notes": draft.projects_notes or "", + "publications": list(draft.publications or []), + "full_name": draft.full_name or "", + "target_role": draft.target_role or "", + "professional_summary": draft.professional_summary or "", + "skills": sorted(draft.skills or []), + }, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _experience_to_dict(entry: WorkExperience) -> dict: + """asdict-style serialisation for cache storage. We dump WorkExperience + via asdict() generally, but `start` / `end` may be dicts (date parts) + so we coerce to the str/None contract the LLM payload uses.""" + return { + "title": entry.title or "", + "organization": entry.organization or "", + "location": entry.location or "", + "description": entry.description or "", + "start": "" if entry.start is None else str(entry.start), + "end": "" if entry.end is None else str(entry.end), + } + + +def _education_to_dict(entry: EducationEntry) -> dict: + return { + "institution": entry.institution or "", + "degree": entry.degree or "", + "field_of_study": getattr(entry, "field_of_study", "") or "", + "start": entry.start or "", + "end": entry.end or "", + } + + +def _experience_from_dict(payload: dict) -> WorkExperience | None: + if not isinstance(payload, dict): + return None + title = str(payload.get("title", "") or "") + organization = str(payload.get("organization", "") or "") + if not title and not organization: + return None + start = str(payload.get("start", "") or "") + end = str(payload.get("end", "") or "") + return WorkExperience( + title=title or "Relevant Experience", + organization=organization, + location=str(payload.get("location", "") or ""), + description=str(payload.get("description", "") or ""), + start=start or None, + end=end or None, + ) + + +def _education_from_dict(payload: dict) -> EducationEntry | None: + if not isinstance(payload, dict): + return None + institution = str(payload.get("institution", "") or "") + degree = str(payload.get("degree", "") or "") + if not institution and not degree: + return None + return EducationEntry( + institution=institution, + degree=degree, + field_of_study=str(payload.get("field_of_study", "") or ""), + start=str(payload.get("start", "") or ""), + end=str(payload.get("end", "") or ""), + ) + + +def _project_to_dict(entry: ProjectEntry) -> dict: + return { + "name": entry.name or "", + "description": entry.description or "", + "bullets": list(entry.bullets or []), + "technologies": list(entry.technologies or []), + "start": entry.start or "", + "end": entry.end or "", + "link": entry.link or "", + } + + +def _project_from_dict(payload: dict) -> ProjectEntry | None: + if not isinstance(payload, dict): + return None + name = str(payload.get("name", "") or "") + if not name: + return None + bullets_value = payload.get("bullets") or [] + technologies_value = payload.get("technologies") or [] + return ProjectEntry( + name=name, + description=str(payload.get("description", "") or ""), + bullets=[str(item) for item in bullets_value if str(item).strip()], + technologies=[str(item) for item in technologies_value if str(item).strip()], + start=str(payload.get("start", "") or ""), + end=str(payload.get("end", "") or ""), + link=str(payload.get("link", "") or ""), + ) + + +def _structure_via_llm( + session: ResumeBuilderSession, + *, + openai_service, +) -> tuple[list[WorkExperience], list[EducationEntry], list[ProjectEntry]] | None: + """LLM-first conversion of free-form notes into structured entries. + + Mirrors the rest of the agent pipeline: the conversational intake + captures user prose, then a structuring pass at generate / export + time turns that prose into the same shape the JD-driven path would + produce. Returns None on ANY failure (service unavailable, JSON + malformed, payload missing keys, no usable entries) so the caller + can fall back to the deterministic regex parsers. + + The fallback is essential — users without OpenAI keys, rate-limited + requests, or transient model errors must still be able to render + their resume. The regex parsers handle those cases correctly even + if the output is less polished than the LLM rewrite. + + Caches the structured payload on the session keyed on a hash of the + structuring prompt's inputs. A re-download (PDF after DOCX, theme + switch, etc.) within the same session reuses the cached entries + instead of re-calling the LLM and getting subtly different bullet + wording. + + Returns a 3-tuple `(experience, education, projects)` — projects + are part of the same structuring pass since they share the same + bullet-rewrite-and-fact-preservation contract. + """ + if openai_service is None or not getattr(openai_service, "is_available", lambda: False)(): + return None + + has_experience = bool(session.draft.experience_notes.strip()) + has_education = bool(session.draft.education_notes.strip()) + has_projects = bool(session.draft.projects_notes.strip()) + if not (has_experience or has_education or has_projects): + # Nothing to structure — short-circuit to avoid burning a token + # budget on an empty payload. Do NOT cache this state because a + # subsequent edit might add prose; the next call will compute a + # different signature and re-evaluate. + return [], [], [] + + current_signature = _structuring_signature(session.draft) + if ( + session.structuring_signature + and session.structuring_signature == current_signature + ): + # Cache hit — rebuild entries from stored payload. Stable + # bullets across re-downloads and the LLM call we save is the + # most expensive part of /generate and /export. + cached_experience = [ + entry + for entry in ( + _experience_from_dict(item) + for item in session.structured_experience_payload + ) + if entry is not None + ] + cached_education = [ + entry + for entry in ( + _education_from_dict(item) + for item in session.structured_education_payload + ) + if entry is not None + ] + cached_projects = [ + entry + for entry in ( + _project_from_dict(item) + for item in session.structured_projects_payload + ) + if entry is not None + ] + return cached_experience, cached_education, cached_projects + + prompt = build_resume_builder_structuring_prompt(draft=asdict(session.draft)) + try: + # Schema-strict path: the structuring output is the biggest / + # most fragile JSON in the workflow (multiple arrays + optional + # categories + optional summary). Production runs through + # ``run_structured_prompt`` so the model is constrained at + # generation time to match ``ResumeBuilderStructuringOutput``. + # Test fakes that only implement the legacy ``run_json_prompt`` + # still work via the ``hasattr`` shim below — the validation + # then happens here in Python rather than at the API edge. + if hasattr(openai_service, "run_structured_prompt"): + structured = openai_service.run_structured_prompt( + prompt["system"], + prompt["user"], + response_model=ResumeBuilderStructuringOutput, + max_completion_tokens=get_openai_max_completion_tokens_for_task( + "resume_builder_structuring" + ), + task_name="resume_builder_structuring", + allow_output_budget_retry=True, + ) + payload = structured.model_dump() + else: + payload = openai_service.run_json_prompt( + prompt["system"], + prompt["user"], + expected_keys=prompt["expected_keys"], + temperature=None, + max_completion_tokens=get_openai_max_completion_tokens_for_task( + "resume_builder_structuring" + ), + task_name="resume_builder_structuring", + allow_output_budget_retry=True, + ) + except Exception as exc: # noqa: BLE001 — any LLM error → fallback + log_event( + LOGGER, + logging.WARNING, + "resume_builder_structuring_failed", + "Resume builder structuring LLM call failed; falling back to regex parser.", + session_id=session.session_id, + error=str(exc), + ) + return None + + if not isinstance(payload, dict): + log_event( + LOGGER, + logging.WARNING, + "resume_builder_structuring_invalid_payload", + "Resume builder structuring returned non-dict payload; falling back to regex.", + session_id=session.session_id, + payload_type=type(payload).__name__, + ) + return None + + experience_items = payload.get("experience") + education_items = payload.get("education") + projects_items = payload.get("projects") + skill_categories_raw = payload.get("skill_categories") + expanded_summary_raw = payload.get("professional_summary") + + experience_entries: list[WorkExperience] = [] + if isinstance(experience_items, list): + for item in experience_items: + entry = _build_experience_entry_from_llm(item) + if entry is not None: + experience_entries.append(entry) + + education_entries: list[EducationEntry] = [] + if isinstance(education_items, list): + for item in education_items: + entry = _build_education_entry_from_llm(item) + if entry is not None: + education_entries.append(entry) + + project_entries: list[ProjectEntry] = [] + if isinstance(projects_items, list): + for item in projects_items: + entry = _build_project_entry_from_llm(item) + if entry is not None: + project_entries.append(entry) + + # If the user typed prose for a section but the LLM returned nothing + # parseable, treat that as a failure for THAT section so the regex + # parser fills the gap. We don't fail the whole call — the LLM + # might handle one section well and the other badly. + if has_experience and not experience_entries: + experience_entries = _build_experience_entries(session.draft.experience_notes) + if has_education and not education_entries: + education_entries = _build_education_entries(session.draft.education_notes) + if has_projects and not project_entries: + project_entries = _build_project_entries(session.draft.projects_notes) + + # Stash the structured result so re-downloads at a different theme + # / format / page-load reuse identical bullets without re-calling + # the LLM. The signature is what gates the next cache lookup; if + # the user edits any of the prompt's input fields, the next + # _structuring_signature() differs and we re-run. + session.structured_experience_payload = [ + _experience_to_dict(entry) for entry in experience_entries + ] + session.structured_education_payload = [ + _education_to_dict(entry) for entry in education_entries + ] + session.structured_projects_payload = [ + _project_to_dict(entry) for entry in project_entries + ] + # Skill categories are stored on the session (not returned in the + # tuple) — _synthesize_resume_builder_artifact reads them after + # build_tailored_resume_artifact and assigns to artifact.skill_categories. + # Validate shape before caching: dict[str, list[str]], every skill + # in the buckets must appear in the user's flat skills list (defends + # against the LLM inventing categories with new tech). + session.structured_skill_categories = _sanitize_skill_categories( + skill_categories_raw, session.draft.skills or [] + ) + # Summary expansion is opt-in by the LLM — only stored when the + # model emits a non-empty replacement and it's actually longer than + # what the user typed (defends against the model summarising + # downward by accident). + expanded_summary = (str(expanded_summary_raw or "") or "").strip() + user_summary_len = len((session.draft.professional_summary or "").strip()) + if expanded_summary and len(expanded_summary) > user_summary_len: + session.structured_professional_summary = expanded_summary + else: + session.structured_professional_summary = "" + session.structuring_signature = current_signature + + return experience_entries, education_entries, project_entries + + +def _sanitize_skill_categories( + raw, allowed_skills: list[str] +) -> dict[str, list[str]]: + """Validate the LLM's skill_categories payload against the user's + flat skill list. Drops any skill the LLM invented; drops empty + buckets; preserves user casing where possible. + + Returns {} on any structural problem so the renderer falls back to + the flat list cleanly. + """ + if not isinstance(raw, dict): + return {} + # Build a case-insensitive lookup of allowed skills, mapping lowercase + # back to the user's original casing. + canon = {str(s).lower().strip(): str(s).strip() for s in allowed_skills if str(s).strip()} + if not canon: + return {} + cleaned: dict[str, list[str]] = {} + for label, items in raw.items(): + if not isinstance(label, str) or not label.strip(): + continue + if not isinstance(items, list): + continue + bucket: list[str] = [] + for item in items: + key = str(item or "").lower().strip() + if key in canon: + bucket.append(canon[key]) + if bucket: + cleaned[label.strip()] = bucket + return cleaned + + +def _build_resume_markdown(draft: ResumeBuilderDraft) -> str: + sections: list[str] = [] + header_name = draft.full_name or "Your Name" + sections.append(f"# {header_name}") + if draft.location: + sections.append(draft.location) + if draft.contact_lines: + sections.append(" | ".join(draft.contact_lines)) + + sections.append("") + sections.append("## Professional Summary") + sections.append(draft.professional_summary or f"Targeting {draft.target_role or 'a new role'} with a grounded profile built through guided intake.") + + sections.append("") + sections.append("## Core Skills") + if draft.skills: + sections.extend(f"- {skill}" for skill in draft.skills) + else: + sections.append("- Add your strongest tools and skills here.") + + sections.append("") + sections.append("## Professional Experience") + if draft.experience_notes: + for line in _normalize_lines(draft.experience_notes): + prefix = "- " if line != _normalize_lines(draft.experience_notes)[0] else "" + sections.append(f"{prefix}{line}" if prefix else line) + else: + sections.append("- Add your most relevant role, impact, and projects here.") + + if draft.projects_notes: + sections.append("") + sections.append("## Projects") + sections.extend(_normalize_lines(draft.projects_notes)) + + sections.append("") + sections.append("## Education") + if draft.education_notes: + sections.extend(_normalize_lines(draft.education_notes)) + else: + sections.append("- Add your education details here.") + + if draft.publications: + sections.append("") + sections.append("## Publications") + sections.extend(f"- {publication}" for publication in draft.publications) + + if draft.certifications: + sections.append("") + sections.append("## Certifications") + sections.extend(f"- {certification}" for certification in draft.certifications) + + return "\n".join(sections).strip() + + +def _build_candidate_profile_and_resume( + session: ResumeBuilderSession, + *, + openai_service=None, +) -> tuple[ResumeDocument, CandidateProfile]: + """Compose the rendered resume + structured CandidateProfile. + + Tries the LLM structuring pass first when an `openai_service` is + provided; falls back to the deterministic regex parsers when the + service is unavailable or the structured output couldn't be + parsed. Either way the call returns the same `(ResumeDocument, + CandidateProfile)` shape, so route handlers don't need to know + which path produced the entries. + """ + markdown = _build_resume_markdown(session.draft) + plain_text = markdown_to_text(markdown, strip_bold=True) + session.generated_resume_markdown = markdown + session.generated_resume_plain_text = plain_text + + structured = _structure_via_llm(session, openai_service=openai_service) + if structured is not None: + experience_entries, education_entries, project_entries = structured + else: + experience_entries = _build_experience_entries(session.draft.experience_notes) + education_entries = _build_education_entries(session.draft.education_notes) + project_entries = _build_project_entries(session.draft.projects_notes) + + resume_document = ResumeDocument( + text=plain_text, + filetype="AI Draft", + source="assistant_builder", + ) + candidate_profile = CandidateProfile( + full_name=session.draft.full_name, + location=session.draft.location, + contact_lines=session.draft.contact_lines, + source="assistant_builder", + resume_text=plain_text, + skills=session.draft.skills, + experience=experience_entries, + education=education_entries, + certifications=session.draft.certifications, + projects=project_entries, + publications=list(session.draft.publications or []), + source_signals=dedupe_strings( + [ + "Profile created with the resume builder assistant.", + f"Target role: {session.draft.target_role}" if session.draft.target_role else "", + "Experience notes captured through guided intake." if session.draft.experience_notes else "", + "Skills were confirmed by the user." if session.draft.skills else "", + "Projects captured through guided intake." if session.draft.projects_notes else "", + "Publications captured through guided intake." if session.draft.publications else "", + ] + ), + ) + return resume_document, candidate_profile + + +def _serialize_session( + session: ResumeBuilderSession, + *, + assistant_message: str | None = None, +): + # `_step_index("review")` returns 0 because "review" isn't in + # RESUME_BUILDER_STEPS — that used to flicker the UI back to 0% + # and drop every DONE badge the moment the user landed on Review. + # Treat "review" (and "ready") as all-steps-complete instead. + if session.current_step == "review" or session.status in {"reviewing", "ready"}: + completed_steps = len(RESUME_BUILDER_STEPS) + else: + completed_steps = min( + _step_index(session.current_step), + len(RESUME_BUILDER_STEPS) - 1, + ) + progress_percent = int((completed_steps / len(RESUME_BUILDER_STEPS)) * 100) + return { + "session_id": session.session_id, + "status": session.status, + "current_step": session.current_step, + "completed_steps": completed_steps, + "total_steps": len(RESUME_BUILDER_STEPS), + "progress_percent": progress_percent, + "assistant_message": assistant_message or _current_prompt(session.current_step), + "draft_profile": asdict(session.draft), + "generated_resume_markdown": session.generated_resume_markdown, + "generated_resume_plain_text": session.generated_resume_plain_text, + "ready_to_generate": session.status in {"reviewing", "ready"}, + "ready_to_commit": bool(session.generated_resume_markdown), + } + + +def export_resume_builder_session_payload(*, session_id: str): + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + return json.dumps( + { + "session_id": session.session_id, + "current_step": session.current_step, + "status": session.status, + "draft_profile": asdict(session.draft), + "generated_resume_markdown": session.generated_resume_markdown, + "generated_resume_plain_text": session.generated_resume_plain_text, + "conversation_history": list(session.conversation_history or []), + # Persist the structuring cache so a container restart + # doesn't force a re-call to the LLM (which would also + # subtly rewrite the bullets). Drops gracefully if the + # session was saved before this field existed. + "structuring_signature": session.structuring_signature, + "structured_experience_payload": list( + session.structured_experience_payload or [] + ), + "structured_education_payload": list( + session.structured_education_payload or [] + ), + "structured_projects_payload": list( + session.structured_projects_payload or [] + ), + "structured_skill_categories": dict( + session.structured_skill_categories or {} + ), + "structured_professional_summary": ( + session.structured_professional_summary or "" + ), + }, + separators=(",", ":"), + ) + + +def restore_resume_builder_session_payload(payload_json: str): + raw_payload = json.loads(str(payload_json or "").strip() or "{}") + if not isinstance(raw_payload, dict): + raise ValueError("Resume builder session payload is invalid.") + + draft_payload = raw_payload.get("draft_profile") or {} + if not isinstance(draft_payload, dict): + raise ValueError("Resume builder session draft payload is invalid.") + + history_payload = raw_payload.get("conversation_history") or [] + if not isinstance(history_payload, list): + history_payload = [] + sanitized_history = [ + { + "role": str(item.get("role", "") or "").strip() or "user", + "content": str(item.get("content", "") or ""), + } + for item in history_payload + if isinstance(item, dict) + ] + + session = ResumeBuilderSession( + session_id=str(raw_payload.get("session_id", "") or uuid4()), + current_step=str(raw_payload.get("current_step", "basics") or "basics"), + status=str(raw_payload.get("status", "collecting") or "collecting"), + draft=ResumeBuilderDraft( + full_name=str(draft_payload.get("full_name", "") or ""), + location=str(draft_payload.get("location", "") or ""), + contact_lines=[ + str(item).strip() + for item in draft_payload.get("contact_lines", []) + if str(item).strip() + ], + target_role=str(draft_payload.get("target_role", "") or ""), + professional_summary=str(draft_payload.get("professional_summary", "") or ""), + experience_notes=str(draft_payload.get("experience_notes", "") or ""), + education_notes=str(draft_payload.get("education_notes", "") or ""), + skills=[ + str(item).strip() + for item in draft_payload.get("skills", []) + if str(item).strip() + ], + certifications=[ + str(item).strip() + for item in draft_payload.get("certifications", []) + if str(item).strip() + ], + projects_notes=str(draft_payload.get("projects_notes", "") or ""), + publications=[ + str(item).strip() + for item in draft_payload.get("publications", []) + if str(item).strip() + ], + ), + generated_resume_markdown=str( + raw_payload.get("generated_resume_markdown", "") or "" + ), + generated_resume_plain_text=str( + raw_payload.get("generated_resume_plain_text", "") or "" + ), + conversation_history=sanitized_history, + structuring_signature=str( + raw_payload.get("structuring_signature", "") or "" + ), + structured_experience_payload=[ + item + for item in (raw_payload.get("structured_experience_payload") or []) + if isinstance(item, dict) + ], + structured_education_payload=[ + item + for item in (raw_payload.get("structured_education_payload") or []) + if isinstance(item, dict) + ], + structured_projects_payload=[ + item + for item in (raw_payload.get("structured_projects_payload") or []) + if isinstance(item, dict) + ], + structured_skill_categories=( + raw_payload.get("structured_skill_categories") or {} + if isinstance(raw_payload.get("structured_skill_categories"), dict) + else {} + ), + structured_professional_summary=str( + raw_payload.get("structured_professional_summary", "") or "" + ), + ) + _SESSIONS[session.session_id] = session + return _serialize_session(session) + + +def has_resume_builder_session(session_id: str) -> bool: + return str(session_id or "").strip() in _SESSIONS + + +def start_resume_builder_session( + *, + access_token: str = "", + refresh_token: str = "", +): + """Begin a new resume-builder intake. + + Quota gate (Step 5 of tier-enforcement): + `resume_builder_sessions` is the special case from the brief: + Free -> lifetime counter, cap 1 (one onboarding ever) + Pro -> monthly counter, cap 3 + Business -> monthly counter, cap 15 + We pass `lifetime=True` to `check_and_increment` ONLY when + `tier == "free"`. Other tiers fall through to the default + monthly period_key. The credit is consumed on session creation + (not per intake turn) so users can chat freely once they're in. + + Failure refund: if the in-memory session insert fails we + refund. Realistically this can only fail if the in-process + dict mutation raises (e.g. interpreter shutdown) -- the gate + pattern is here for consistency with the rest of the series. + + Anonymous flow: when no auth tokens are passed the gate skips + and the session is created without any credit being charged. + Anonymous resume-builder usage was already the existing + pre-quota behavior; we preserve it. + """ + auth_context = None + if access_token and refresh_token: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + + app_user = getattr(auth_context, "app_user", None) if auth_context is not None else None + tier = resolve_user_tier(app_user) + quota_user_id = str(getattr(app_user, "id", "") or "") if app_user is not None else "" + # Lifetime ONLY on Free -- Pro and Business get monthly slots. + lifetime = tier == "free" + quota_consumed = False + if quota_user_id: + quota.check_and_increment( + "resume_builder_sessions", + quota_user_id, + tier, + lifetime=lifetime, + ) + quota_consumed = True + + try: + session = ResumeBuilderSession(session_id=str(uuid4())) + _SESSIONS[session.session_id] = session + return _serialize_session(session) + except BaseException: + if quota_consumed: + try: + quota.refund( + "resume_builder_sessions", + quota_user_id, + tier, + lifetime=lifetime, + ) + except Exception: # noqa: BLE001 - refund is best-effort + log_event( + LOGGER, + logging.WARNING, + "resume_builder_session_quota_refund_failed", + "Refund after resume-builder session creation failure " + "raised; user credit was not restored.", + counter="resume_builder_sessions", + user_id=quota_user_id, + tier=tier, + lifetime=lifetime, + ) + raise + + +_VALID_STATUSES = {"collecting", "reviewing", "ready"} + + +def _coerce_string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item or "").strip()] + + +def _augment_full_name_from_message( + session: ResumeBuilderSession, user_message: str +) -> None: + """Safety net for LLM full_name truncation. + + The LLM intake sometimes captures only the first token of a + multi-word name when the user squashes everything onto one line + ("Priya Sharma, Bangalore. priya@gmail.com" → 'Priya'). This helper + looks at the user's literal message; if its first chunk is a + longer, valid-looking name that starts with what the LLM captured, + we promote the longer version. Prefix-only check so we don't + overwrite an LLM correction (e.g., user later says "actually it's + Maya Sharma"). + """ + llm_name = (session.draft.full_name or "").strip() + if not llm_name: + return + + text = str(user_message or "").strip() + if not text: + return + + # First sentence-or-comma chunk of the user's literal message. + first_chunk = re.split(r"[\n,;|.!?]", text, maxsplit=1)[0].strip() + first_chunk = _NAME_PREAMBLE_PATTERN.sub("", first_chunk).strip() + if not first_chunk or first_chunk == llm_name: + return + + # Only promote when the LLM's name is a strict prefix of the literal + # chunk AND the literal chunk passes our name-shape heuristic. The + # prefix gate prevents accidentally overwriting an LLM correction + # ("user typed 'Priya Sharma' but really meant 'Maya Sharma'") — + # if the literal chunk doesn't start with what the LLM captured, + # we trust the LLM's read. + lower_chunk = first_chunk.lower() + lower_llm = llm_name.lower() + if not lower_chunk.startswith(lower_llm): + return + # Require a whole-word match — guards against "Pri" vs "Priya Sharma". + boundary_index = len(llm_name) + if boundary_index < len(first_chunk): + next_char = first_chunk[boundary_index] + if next_char.isalnum(): + return + if not _looks_like_personal_name(first_chunk): + return + + session.draft.full_name = first_chunk + + +def _apply_llm_draft_updates(session: ResumeBuilderSession, updates: dict): + """Merge a partial dict of resume-builder fields into the session's + draft. Mirrors the shape of `_apply_draft_updates` but only writes + keys present in `updates` (so the LLM can return a partial).""" + if not isinstance(updates, dict): + return + if "full_name" in updates: + session.draft.full_name = str(updates.get("full_name") or "").strip() + if "location" in updates: + session.draft.location = str(updates.get("location") or "").strip() + if "contact_lines" in updates: + session.draft.contact_lines = dedupe_strings( + _coerce_string_list(updates.get("contact_lines")) + ) + if "target_role" in updates: + session.draft.target_role = str(updates.get("target_role") or "").strip() + if "professional_summary" in updates: + session.draft.professional_summary = str( + updates.get("professional_summary") or "" + ).strip() + if "experience_notes" in updates: + session.draft.experience_notes = str( + updates.get("experience_notes") or "" + ).strip() + if "education_notes" in updates: + session.draft.education_notes = str( + updates.get("education_notes") or "" + ).strip() + if "skills" in updates: + session.draft.skills = dedupe_strings(_coerce_string_list(updates.get("skills"))) + if "certifications" in updates: + session.draft.certifications = dedupe_strings( + _coerce_string_list(updates.get("certifications")) + ) + if "projects_notes" in updates: + session.draft.projects_notes = str( + updates.get("projects_notes") or "" + ).strip() + if "publications" in updates: + session.draft.publications = dedupe_strings( + _coerce_string_list(updates.get("publications")) + ) + + +def _run_llm_turn( + *, + session: ResumeBuilderSession, + user_message: str, + openai_service, +): + """Drive one conversational turn through the LLM intake prompt. + + Returns the assistant message text on success. Mutates the session + in place: applies `draft_updates`, updates `status` and + `current_step`, appends the user/assistant turn pair to + `conversation_history`. Raises `AgentExecutionError` on any failure + so the caller can swallow it and fall back to the regex flow. + """ + if openai_service is None or not openai_service.is_available(): + raise AgentExecutionError("OpenAI service is not available for resume builder intake.") + + prompt = build_resume_builder_prompt( + draft=asdict(session.draft), + history=session.conversation_history, + user_message=user_message, + ) + payload = openai_service.run_json_prompt( + prompt["system"], + prompt["user"], + expected_keys=prompt["expected_keys"], + temperature=None, + max_completion_tokens=get_openai_max_completion_tokens_for_task("resume_builder"), + task_name="resume_builder", + allow_output_budget_retry=False, + ) + + draft_updates = payload.get("draft_updates") + if isinstance(draft_updates, dict): + _apply_llm_draft_updates(session, draft_updates) + # Safety net: if the LLM dropped a surname when the user clearly + # typed a full name, recover it from the literal message before + # downstream rendering bakes "Priya" into a resume header. + if "full_name" in draft_updates: + _augment_full_name_from_message(session, user_message) + + assistant_message = str(payload.get("assistant_message") or "").strip() + if not assistant_message: + raise AgentExecutionError("LLM returned an empty assistant_message.") + + raw_status = str(payload.get("status") or "").strip().lower() + status = raw_status if raw_status in _VALID_STATUSES else "collecting" + session.status = status + + focus_field = str(payload.get("focus_field") or "").strip() + if status == "ready": + session.current_step = "review" + elif status == "reviewing": + session.current_step = "review" + elif focus_field and any(focus_field == key for key, _ in RESUME_BUILDER_STEPS): + # Map LLM focus_field back to a step key for legacy + # `current_step` consumers (UI progress indicator, etc.). + session.current_step = focus_field + elif focus_field in {"full_name", "location", "contact_lines"}: + session.current_step = "basics" + elif focus_field in {"professional_summary", "target_role"}: + session.current_step = "role" + elif focus_field in {"experience_notes"}: + session.current_step = "experience" + elif focus_field in {"education_notes", "certifications"}: + session.current_step = "education" + elif focus_field in {"skills"}: + session.current_step = "skills" + # If the model returned no focus_field, leave current_step alone. + + session.conversation_history.append( + {"role": "user", "content": user_message} + ) + session.conversation_history.append( + {"role": "assistant", "content": assistant_message} + ) + # Cap memory: keep only the last 24 turn pairs so a long session + # doesn't blow the prompt budget. The model still sees enough + # context for back-references; older turns are summarized by the + # current `draft` state itself. + if len(session.conversation_history) > 48: + session.conversation_history = session.conversation_history[-48:] + + return assistant_message + + +def _advance_step_after_regex_apply(session: ResumeBuilderSession, current_step: str): + """Tick the step machine forward after a deterministic _apply_* + call. Pulled out of `answer_resume_builder_message` so the regex + fallback path and the legacy regex-only path share the same + advancement logic.""" + current_index = _step_index(current_step) + next_index = current_index + 1 + next_step = ( + RESUME_BUILDER_STEPS[next_index][0] + if next_index < len(RESUME_BUILDER_STEPS) + else None + ) + + if next_step: + session.current_step = next_step + session.status = "collecting" + else: + session.current_step = "review" + session.status = "reviewing" + return next_step + + +def answer_resume_builder_message( + *, + session_id: str, + message: str, + openai_service=None, +): + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + + normalized_message = str(message or "").strip() + if not normalized_message: + raise ValueError("Add an answer before continuing.") + + # LLM-first path: when an OpenAIService is available, the model + # extracts fields, picks the next question, and produces the + # conversational reply. The regex / step-machine path below stays + # as the safety net so the feature still works without an API key, + # on JSON-decode failures, or on any other LLM error. + if openai_service is not None and openai_service.is_available(): + try: + assistant_message = _run_llm_turn( + session=session, + user_message=normalized_message, + openai_service=openai_service, + ) + return _serialize_session( + session, + assistant_message=assistant_message, + ) + except AgentExecutionError as exc: + log_event( + LOGGER, + logging.WARNING, + "resume_builder_llm_fallback", + "Resume-builder LLM turn failed; falling back to deterministic intake.", + session_id=session.session_id, + error_type=type(exc).__name__, + error_message=exc.user_message, + ) + except Exception as exc: # pragma: no cover - defensive + LOGGER.exception( + "Resume-builder LLM turn raised unexpectedly.", + extra={"session_id": session.session_id}, + ) + + current_step = session.current_step + if current_step == "basics": + _apply_basics(session, normalized_message) + elif current_step == "role": + _apply_role(session, normalized_message) + elif current_step == "experience": + _apply_experience(session, normalized_message) + elif current_step == "education": + _apply_education(session, normalized_message) + elif current_step == "skills": + _apply_skills(session, normalized_message) + + next_step = _advance_step_after_regex_apply(session, current_step) + + return _serialize_session( + session, + assistant_message=_build_next_message(current_step, next_step), + ) + + +def generate_resume_builder_resume(*, session_id: str, openai_service=None): + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + + resume_document, candidate_profile = _build_candidate_profile_and_resume( + session, + openai_service=openai_service, + ) + session.status = "ready" + + payload = _serialize_session( + session, + assistant_message="Your base resume draft is ready. Review it and use this profile when you want to continue into the workspace.", + ) + payload["resume_document"] = asdict(resume_document) + payload["candidate_profile"] = asdict(candidate_profile) + return payload + + +def update_resume_builder_session(*, session_id: str, draft_updates: dict): + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + + normalized_updates = dict(draft_updates or {}) + _apply_draft_updates(session, normalized_updates) + + return _serialize_session( + session, + assistant_message="Draft updated. Keep answering prompts or generate the base resume when you are ready.", + ) + + +def commit_resume_builder_session(*, session_id: str, openai_service=None): + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + + resume_document, candidate_profile = _build_candidate_profile_and_resume( + session, + openai_service=openai_service, + ) + session.status = "ready" + return { + "resume_document": asdict(resume_document), + "candidate_profile": asdict(candidate_profile), + "generated_resume_markdown": session.generated_resume_markdown, + "generated_resume_plain_text": session.generated_resume_plain_text, + "builder_session_id": session.session_id, + } + + +_DOCX_MIME_TYPE = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +) + + +def _synthesize_resume_builder_artifact( + session: ResumeBuilderSession, + *, + theme: str, + openai_service=None, +): + """Build a TailoredResumeArtifact from a resume-builder session. + + Phase 5 of the DOCX export plan. The resume-builder is a separate + intake surface (no JD context, no agent_result), so we synthesize + empty JobDescription / FitAnalysis / TailoredResumeDraft objects + and let `build_tailored_resume_artifact` do the structural work. + Section order falls out of `compute_section_order(candidate_profile)` + via `_resolve_section_order` in the artifact builder. + + The artifact's title and filename_stem are then overridden so the + download reads as a generic base resume rather than a "Tailored + Resume" — that wording belongs to JD-driven exports, not the + builder's exit point. + """ + _, candidate_profile = _build_candidate_profile_and_resume( + session, + openai_service=openai_service, + ) + + job_description = JobDescription( + title=session.draft.target_role or "", + raw_text="", + cleaned_text="", + requirements=JobRequirements(), + ) + fit_analysis = FitAnalysis( + target_role=session.draft.target_role or "", + overall_score=0, + readiness_label="", + # Surfacing the user's confirmed skills as 'matched' lets the + # artifact builder's highlighted_skills merge logic still pick + # them up; without this, highlighted_skills could collapse to + # an empty list when the agent_result is absent. + matched_hard_skills=list(session.draft.skills or []), + ) + tailored_draft = TailoredResumeDraft( + target_role=session.draft.target_role or "", + professional_summary=session.draft.professional_summary or "", + highlighted_skills=list(session.draft.skills or []), + ) + + artifact = build_tailored_resume_artifact( + candidate_profile, + job_description, + fit_analysis, + tailored_draft, + theme=theme, + ) + + # Skill categories are produced by _structure_via_llm and cached on + # the session. The artifact builder doesn't know about them, so we + # paint them on after the fact. The renderer prefers categories + # over the flat highlighted_skills list when present. + if session.structured_skill_categories: + artifact.skill_categories = dict(session.structured_skill_categories) + # Same pattern for the expanded summary — only override when the + # structuring pass produced one (otherwise keep what the artifact + # builder set from the user's verbatim summary). + if session.structured_professional_summary: + artifact.professional_summary = session.structured_professional_summary + artifact.summary = session.structured_professional_summary + + name = (candidate_profile.full_name or "Candidate").strip() or "Candidate" + target_role = (session.draft.target_role or "").strip() + if target_role: + artifact.title = f"{name} - {target_role} Resume" + slug_source = f"{name}-{target_role}-resume" + else: + artifact.title = f"{name} Resume" + slug_source = f"{name}-resume" + artifact.filename_stem = slugify_text(slug_source, fallback="resume") + + return artifact + + +def export_resume_builder_artifact( + *, + session_id: str, + export_format: str, + theme: str = "classic_ats", + openai_service=None, +): + """Render the builder's generated resume as PDF or DOCX bytes. + + Returns a dict shaped the same as + `backend.services.artifact_export_service.export_workspace_artifact` + so the frontend's `downloadBase64File` helper handles both the + workspace and resume-builder downloads identically. Auth gating + + session hydration on a cache miss live in the route handler; + this function only depends on the in-memory session being present. + """ + import base64 + + session = _SESSIONS.get(str(session_id or "").strip()) + if session is None: + raise ValueError("Resume builder session not found.") + + normalized_format = str(export_format or "").strip().lower() + if normalized_format not in {"pdf", "docx"}: + raise ValueError("Choose a supported export format.") + + normalized_theme = str(theme or "").strip() + if normalized_theme not in {"classic_ats", "professional_neutral"}: + normalized_theme = "classic_ats" + + artifact = _synthesize_resume_builder_artifact( + session, + theme=normalized_theme, + openai_service=openai_service, + ) + + if normalized_format == "pdf": + payload = export_pdf_bytes(artifact) + mime_type = "application/pdf" + file_name = f"{artifact.filename_stem or 'resume'}.pdf" + else: + payload = export_docx_bytes(artifact) + mime_type = _DOCX_MIME_TYPE + file_name = f"{artifact.filename_stem or 'resume'}.docx" + + return { + "status": "ready", + "export_format": normalized_format, + "file_name": file_name, + "mime_type": mime_type, + "content_base64": base64.b64encode(payload).decode("ascii"), + "theme": normalized_theme, + "artifact_title": artifact.title, + } diff --git a/backend/services/saved_jobs_service.py b/backend/services/saved_jobs_service.py new file mode 100644 index 0000000..52aa951 --- /dev/null +++ b/backend/services/saved_jobs_service.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from typing import Any + +from backend.quota import current_period_key +from backend.services.auth_session_service import resolve_authenticated_context +from backend.tiers import TIER_CAPS, UNLIMITED, resolve_user_tier +from src.cached_jobs_store import CachedJobsStore +from src.errors import InputValidationError, QuotaExceededError +from src.saved_jobs_store import SavedJobsStore + + +def _serialize(value: Any): + if is_dataclass(value): + return {key: _serialize(item) for key, item in asdict(value).items()} + if isinstance(value, dict): + return {key: _serialize(item) for key, item in value.items()} + if isinstance(value, list): + return [_serialize(item) for item in value] + return value + + +def _saved_job_sort_key(job_posting: dict[str, Any]): + return ( + str(job_posting.get("saved_at", "") or ""), + str(job_posting.get("posted_at", "") or ""), + str(job_posting.get("title", "") or "").lower(), + ) + + +def _normalize_saved_job(payload: dict[str, Any] | Any): + raw_payload = _serialize(payload) + metadata = raw_payload.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + return { + "id": str(raw_payload.get("job_id", raw_payload.get("id", "")) or ""), + "source": str(raw_payload.get("source", "") or ""), + "title": str(raw_payload.get("title", "") or ""), + "company": str(raw_payload.get("company", "") or ""), + "location": str(raw_payload.get("location", "") or ""), + "employment_type": str(raw_payload.get("employment_type", "") or ""), + "url": str(raw_payload.get("url", "") or ""), + "summary": str(raw_payload.get("summary", "") or ""), + "description_text": str(raw_payload.get("description_text", "") or ""), + "posted_at": str(raw_payload.get("posted_at", "") or ""), + "scraped_at": str(raw_payload.get("scraped_at", "") or ""), + "metadata": metadata, + "saved_at": str(raw_payload.get("saved_at", "") or ""), + "updated_at": str(raw_payload.get("updated_at", "") or ""), + # Default optimistic — overwritten by _annotate_listing_status + # when we have a cache to consult. The UI shows an "Expired" + # badge when this is False. + "is_listing_active": True, + } + + +def _annotate_listing_status(jobs: list[dict[str, Any]]): + """Look up the cache to find which saved jobs are still listed + upstream. Mutates `jobs` in place — sets is_listing_active=False + on any whose cached_jobs row has removed_at set (the smart cleanup + tombstoned it after the upstream board stopped returning it). + + No-ops gracefully when the cache isn't configured, so local-dev + workflows without SUPABASE_SERVICE_ROLE_KEY don't break.""" + if not jobs: + return + cache = CachedJobsStore() + if not cache.is_configured(): + return + keys = [(str(j.get("source", "")), str(j.get("id", ""))) for j in jobs] + try: + status_map = cache.get_listing_status_map(keys) + except Exception: + # Don't poison the saved-jobs response on a cache outage. + return + for job in jobs: + key = (str(job.get("source", "")), str(job.get("id", ""))) + is_active = status_map.get(key, True) + job["is_listing_active"] = bool(is_active) + + +def list_saved_jobs(*, access_token: str, refresh_token: str): + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + saved_jobs_store = SavedJobsStore(context.auth_service) + if not saved_jobs_store.is_configured(): + raise RuntimeError("Saved jobs persistence is not configured.") + + saved_jobs = [ + _normalize_saved_job(item) + for item in saved_jobs_store.list_jobs( + access_token, + refresh_token, + context.app_user.id, + ) + ] + saved_jobs.sort(key=_saved_job_sort_key, reverse=True) + _annotate_listing_status(saved_jobs) + + latest_saved_at = "" + for item in saved_jobs: + saved_at = str(item.get("saved_at", "") or "").strip() + if saved_at: + latest_saved_at = max(latest_saved_at, saved_at) + + return { + "status": "available", + "saved_jobs": saved_jobs, + "total_saved_jobs": len(saved_jobs), + "latest_saved_at": latest_saved_at, + } + + +def save_saved_job( + *, + access_token: str, + refresh_token: str, + job_posting: dict[str, Any] | None, +): + """Persist one job to the user's shortlist. + + Quota gate (Step 6 of tier-enforcement): + `saved_jobs` is a PERSISTENT row-count cap, not a period-keyed + counter: Free 5 / Pro 1000 / Business UNLIMITED. The brief calls + this out as a different pattern from the period-keyed counters + -- we count existing rows via `SavedJobsStore.list_jobs` and + compare to `TIER_CAPS[tier]["saved_jobs"]`, rather than going + through `quota.check_and_increment`. No refund logic because + we're not incrementing anything. + + The cap check is skipped when the tier cap is UNLIMITED (-1) so + Business saves never read the existing-row count -- they go + straight to the upsert. The store's upsert key is + (user_id, job_id), so re-saving the SAME job at the cap is + allowed (it's an update, not a new row). + """ + normalized_job = dict(job_posting or {}) + job_id = str(normalized_job.get("id", "") or "").strip() + if not job_id: + raise InputValidationError( + "This job is missing a stable id and cannot be saved safely." + ) + + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + saved_jobs_store = SavedJobsStore(context.auth_service) + if not saved_jobs_store.is_configured(): + raise RuntimeError("Saved jobs persistence is not configured.") + + # Persistent-count quota gate. Skip when the tier cap is UNLIMITED + # (Business) so we don't even pay the list-jobs round-trip. For + # capped tiers, count existing rows and compare to the cap; re-saving + # the SAME job_id is fine (the upsert below is an update, not a new + # row). + tier = resolve_user_tier(context.app_user) + cap = TIER_CAPS[tier]["saved_jobs"] + quota_user_id = str(getattr(context.app_user, "id", "") or "") + if cap != UNLIMITED and quota_user_id: + # The store's default page size of 20 is too small to inspect a + # capped quota -- a Pro user at 999 saved jobs would silently + # bypass the gate. Pass an explicit limit one above the cap so + # we always know whether the user is at-or-over. + existing_jobs = saved_jobs_store.list_jobs( + access_token, + refresh_token, + quota_user_id, + limit=cap + 1, + ) + existing_ids = {str(getattr(record, "job_id", "")) for record in existing_jobs} + # Allow re-saves of an already-saved job_id -- the upsert below + # treats that as an UPDATE (same row), not a new row. Without + # this carve-out a user at the cap could never re-save a job + # they edited. + if job_id not in existing_ids and len(existing_jobs) >= cap: + raise QuotaExceededError( + "You have reached the saved-jobs limit for your plan. " + "Remove an existing saved job to make room, or upgrade " + "to continue saving more.", + counter="saved_jobs", + current=len(existing_jobs), + cap=cap, + reset_period=current_period_key(), # informational only -- persistent counters don't reset + tier=tier, + ) + + saved_job = saved_jobs_store.save_job( + access_token, + refresh_token, + { + "user_id": context.app_user.id, + "job_id": job_id, + **normalized_job, + }, + ) + normalized_saved_job = _normalize_saved_job(saved_job) + return { + "status": "saved", + "saved_job": normalized_saved_job, + "message": "Saved {title} to your shortlist.".format( + title=normalized_saved_job.get("title", "job") + ), + } + + +def remove_saved_job(*, access_token: str, refresh_token: str, job_id: str): + normalized_job_id = str(job_id or "").strip() + if not normalized_job_id: + raise InputValidationError( + "This job is missing a stable id and cannot be removed safely." + ) + + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + saved_jobs_store = SavedJobsStore(context.auth_service) + if not saved_jobs_store.is_configured(): + raise RuntimeError("Saved jobs persistence is not configured.") + + saved_jobs_store.delete_job( + access_token, + refresh_token, + context.app_user.id, + normalized_job_id, + ) + return { + "status": "removed", + "job_id": normalized_job_id, + } diff --git a/backend/services/workspace_persistence_service.py b/backend/services/workspace_persistence_service.py new file mode 100644 index 0000000..17b0663 --- /dev/null +++ b/backend/services/workspace_persistence_service.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, is_dataclass +from typing import Any + +from backend.quota import current_period_key +from backend.services.auth_session_service import resolve_authenticated_context +from backend.tiers import TIER_CAPS, UNLIMITED, resolve_user_tier +from src.cover_letter_builder import build_cover_letter_artifact +from src.errors import QuotaExceededError +from src.resume_builder import build_tailored_resume_artifact +from src.saved_workspace_store import SavedWorkspaceStore +from src.services.jd_summary_service import generate_job_summary_view +from src.workflow_payloads import ( + WORKFLOW_HISTORY_PAYLOAD_KIND_COVER_LETTER, + WORKFLOW_HISTORY_PAYLOAD_KIND_SNAPSHOT, + WORKFLOW_HISTORY_PAYLOAD_KIND_TAILORED_RESUME, + build_saved_cover_letter_from_payload, + build_saved_tailored_resume_from_payload, + build_saved_workflow_snapshot_from_payload, + versioned_payload, +) + + +def _serialize(value: Any): + if is_dataclass(value): + return {key: _serialize(item) for key, item in asdict(value).items()} + if isinstance(value, dict): + return {key: _serialize(item) for key, item in value.items()} + if isinstance(value, list): + return [_serialize(item) for item in value] + return value + + +def _workspace_signature(snapshot: dict[str, Any]): + """Stable 64-char sha256 of the canonical workspace payload. + + Previously this stored the full `json.dumps(...)` (~50KB) on every + saved-workspace row. The signature is write-only — no consumer + parses it — so the hash is just as useful as a change-detection + fingerprint while keeping the row trivially small.""" + payload = { + "candidate_profile": snapshot.get("candidate_profile") or {}, + "job_description": snapshot.get("job_description") or {}, + "fit_analysis": snapshot.get("fit_analysis") or {}, + "tailored_draft": snapshot.get("tailored_draft") or {}, + } + canonical = json.dumps(payload, sort_keys=True, default=str) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _validate_workspace_snapshot(snapshot: dict[str, Any] | None): + payload = dict(snapshot or {}) + required_sections = [ + "candidate_profile", + "job_description", + "fit_analysis", + "tailored_draft", + "artifacts", + ] + for section in required_sections: + if not isinstance(payload.get(section), dict): + raise ValueError(section) + return payload + + +def save_workspace_snapshot( + *, + access_token: str, + refresh_token: str, + workspace_snapshot: dict[str, Any] | None, +): + """Persist (or overwrite) the user's saved workspace. + + Quota gate (Step 6 of tier-enforcement): + `saved_workspaces` is a PERSISTENT row-count cap, NOT a + period-keyed counter: Free 1 / Pro 5 / Business UNLIMITED. + + Today's SavedWorkspaceStore upserts on user_id, so a single + user only ever has at most ONE saved-workspace row -- the + Free=1 cap is automatically satisfied by the upsert, and + Pro=5 / Business=UNLIMITED are effectively unused capacity + against the current schema. The gate enforces the cap + generically anyway so: + (a) when the schema migrates to multi-row saved workspaces + (e.g. one row per saved_workspace_slug or uuid), the + gate already enforces the Pro=5 ceiling without + further changes, and + (b) the structured 429 surface is consistent across saved + jobs / saved workspaces / period-keyed counters. + + Eviction policy at-cap: REJECT, do not auto-evict the oldest. + Auto-eviction is surprising; users should explicitly delete a + saved workspace to make room. The 429 message points them at + that affordance. + + Re-saving the SAME workspace (same user_id under the upsert + semantic) is always allowed -- the cap counts distinct slots, + not lifetime write operations. + """ + try: + snapshot = _validate_workspace_snapshot(workspace_snapshot) + except ValueError as exc: + raise ValueError(f"workspace_snapshot.{exc.args[0]}") + + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + saved_workspace_store = SavedWorkspaceStore(context.auth_service) + if not saved_workspace_store.is_configured(): + raise RuntimeError("Saved workspace persistence is not configured.") + + # Persistent-count quota gate. Skip when the tier cap is UNLIMITED + # (Business). For capped tiers, the rule is: a NEW slot creation + # counts toward the cap; UPDATES to an existing slot do not. + # + # Today's store upserts on user_id (one row per user). So when the + # user already has an "available" record, this save is an UPDATE to + # their existing slot, not a new slot — the cap doesn't apply and + # the save proceeds. This is critical for the frontend autosave UX: + # every snapshot refresh after the first save would otherwise 429 + # for Free users (cap=1, 1 >= 1 blocks the upsert). + # + # When the schema migrates to per-slot rows (e.g. one row per + # saved-workspace slug, future PR), `existing_count` becomes the + # user's actual distinct-slot count and `is_creating_new_slot` + # becomes True only when the save's slug isn't already in the + # user's set. The gate logic then naturally blocks the (cap+1)-th + # distinct slot without revisiting this code path. + # + # The 429 message + the saved_jobs gate's parallel "delete to make + # room" UX still apply for genuinely-new slot creation; this just + # carves out the upsert path so autosave doesn't break under tier + # caps the user hasn't actually exceeded. + tier = resolve_user_tier(context.app_user) + cap = TIER_CAPS[tier]["saved_workspaces"] + quota_user_id = str(getattr(context.app_user, "id", "") or "") + if cap != UNLIMITED and quota_user_id: + existing_record, existing_status = saved_workspace_store.load_workspace( + access_token, + refresh_token, + quota_user_id, + ) + is_existing_slot_update = ( + existing_status == "available" and existing_record is not None + ) + existing_count = 1 if is_existing_slot_update else 0 + is_creating_new_slot = not is_existing_slot_update + if is_creating_new_slot and existing_count >= cap: + raise QuotaExceededError( + "You have reached the saved-workspaces limit for your " + "plan. Delete an existing saved workspace to make room, " + "or upgrade to continue saving more.", + counter="saved_workspaces", + current=existing_count, + cap=cap, + reset_period=current_period_key(), # informational only -- persistent counters don't reset + tier=tier, + ) + + artifacts = dict(snapshot.get("artifacts") or {}) + record = saved_workspace_store.save_workspace( + access_token, + refresh_token, + { + "user_id": context.app_user.id, + "job_title": str( + snapshot.get("job_description", {}).get("title", "") or "" + ), + "workflow_signature": _workspace_signature(snapshot), + "workflow_snapshot_json": versioned_payload( + WORKFLOW_HISTORY_PAYLOAD_KIND_SNAPSHOT, + { + "candidate_profile": snapshot.get("candidate_profile") or {}, + "job_description": snapshot.get("job_description") or {}, + "fit_analysis": snapshot.get("fit_analysis") or {}, + "tailored_draft": snapshot.get("tailored_draft") or {}, + "agent_result": snapshot.get("agent_result"), + "imported_job_posting": snapshot.get("imported_job_posting"), + }, + ), + "cover_letter_payload_json": versioned_payload( + WORKFLOW_HISTORY_PAYLOAD_KIND_COVER_LETTER, + artifacts.get("cover_letter") or {}, + ), + "tailored_resume_payload_json": versioned_payload( + WORKFLOW_HISTORY_PAYLOAD_KIND_TAILORED_RESUME, + artifacts.get("tailored_resume") or {}, + ), + }, + ) + return { + "status": "saved", + "saved_workspace": { + "job_title": record.job_title, + "expires_at": record.expires_at, + "updated_at": record.updated_at, + }, + } + + +def load_saved_workspace_snapshot(*, access_token: str, refresh_token: str): + context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + saved_workspace_store = SavedWorkspaceStore(context.auth_service) + if not saved_workspace_store.is_configured(): + raise RuntimeError("Saved workspace persistence is not configured.") + + record, status = saved_workspace_store.load_workspace( + access_token, + refresh_token, + context.app_user.id, + ) + if record is None: + return { + "status": status, + "saved_workspace": None, + } + + saved_snapshot = build_saved_workflow_snapshot_from_payload( + record.workflow_snapshot_json + ) + if saved_snapshot is None: + raise RuntimeError( + "The saved workspace could not be restored safely. Re-run the flow to create a fresh save." + ) + + saved_tailored_resume_artifact = build_saved_tailored_resume_from_payload( + record.tailored_resume_payload_json + ) + tailored_resume_artifact = build_tailored_resume_artifact( + saved_snapshot.candidate_profile, + saved_snapshot.job_description, + saved_snapshot.fit_analysis, + saved_snapshot.tailored_draft, + agent_result=saved_snapshot.agent_result, + theme=( + getattr(saved_tailored_resume_artifact, "theme", None) + or "classic_ats" + ), + ) + cover_letter_artifact = build_saved_cover_letter_from_payload( + record.cover_letter_payload_json + ) or build_cover_letter_artifact( + saved_snapshot.candidate_profile, + saved_snapshot.job_description, + saved_snapshot.fit_analysis, + saved_snapshot.tailored_draft, + agent_result=saved_snapshot.agent_result, + ) + + workspace_snapshot = { + "resume_document": { + "text": saved_snapshot.candidate_profile.resume_text, + "filetype": "Saved Workspace", + "source": "saved_workspace", + }, + "candidate_profile": _serialize(saved_snapshot.candidate_profile), + "job_description": _serialize(saved_snapshot.job_description), + "jd_summary_view": generate_job_summary_view( + openai_service=None, + job_description=saved_snapshot.job_description, + imported_job_posting=saved_snapshot.imported_job_posting, + ), + "fit_analysis": _serialize(saved_snapshot.fit_analysis), + "tailored_draft": _serialize(saved_snapshot.tailored_draft), + "agent_result": _serialize(saved_snapshot.agent_result), + "artifacts": { + "tailored_resume": _serialize(tailored_resume_artifact), + "cover_letter": _serialize(cover_letter_artifact), + }, + "workflow": { + "mode": getattr(saved_snapshot.agent_result, "mode", "") or "saved_workspace", + "assisted_requested": bool(saved_snapshot.agent_result), + "assisted_available": True, + "review_approved": bool( + getattr(getattr(saved_snapshot.agent_result, "review", None), "approved", False) + ), + "fallback_reason": str( + getattr(saved_snapshot.agent_result, "fallback_reason", "") or "" + ), + }, + "imported_job_posting": _serialize(saved_snapshot.imported_job_posting), + } + + return { + "status": "available", + "saved_workspace": { + "job_title": record.job_title, + "expires_at": record.expires_at, + "updated_at": record.updated_at, + }, + "workspace_snapshot": workspace_snapshot, + } diff --git a/backend/services/workspace_quota_service.py b/backend/services/workspace_quota_service.py new file mode 100644 index 0000000..c594d57 --- /dev/null +++ b/backend/services/workspace_quota_service.py @@ -0,0 +1,295 @@ +"""Read-only quota snapshot for /workspace/quota (Step 7b). + +The endpoint drives three frontend behaviors: + + * **Premium toggle gating** — `premium_available` is True only on + tiers whose `premium_applications` cap is > 0, so the toggle can + render disabled+tooltip on Free without a second lookup. + * **Per-counter indicators** — each counter snapshot carries + current / limit / remaining so the UI can show "you have N + premium credits left this month" inline next to the toggle. + * **Upgrade CTA target** — `upgrade_url` is read from the env so + prod / staging / dev can each point at the right pricing page. + +The endpoint is strictly read-only — calling it never increments a +counter, never writes to Supabase, never burns quota credit. It's safe +to call on every workspace mount + after every workflow run, which the +frontend does to keep the toggle's indicator in sync with the actual +backend state. + +For period-keyed counters (tailored / premium applications, assistant +turns, resume parses, job searches), the snapshot reads from the +quota counters table via `quota.read_counter`. For lifetime counters +(Free-tier resume_builder_sessions), the same helper handles the +period_key swap. For persistent row-count caps (saved_jobs, +saved_workspaces) we read the existing row count from the +corresponding Supabase store and surface it as `current`. +""" +from __future__ import annotations + +from typing import Any + +from backend import quota +from backend.tiers import TIER_CAPS, UNLIMITED, Tier, resolve_user_tier +from backend.services.auth_session_service import resolve_authenticated_context +from src.errors import AppError +from src.saved_jobs_store import SavedJobsStore +from src.saved_workspace_store import SavedWorkspaceStore + + +class WorkspaceQuotaAuthRequired(RuntimeError): + """Signaled by `get_workspace_quota_snapshot` when no usable auth + context is available. The route converts this to a 401 -- the + /workspace/quota response makes no sense for an anonymous caller. + + Local module-private exception type (not exported on src.errors) + because it only carries information across the service/route + boundary in one direction; the global QuotaExceededError handler + chain has no role here. Naming makes the route's except branch + self-documenting. + """ + + +# Per-counter metadata: which reset_period label to surface to the UI +# and whether the counter is lifetime-keyed for the Free tier. +# +# "monthly" - calendar-month reset (period_key = YYYY-MM). +# "lifetime" - Free tier only; resets never. Pro/Business use the +# same counter name but the monthly partition. +# "persistent" - row count cap (saved_jobs, saved_workspaces). The +# UI renders this as "saved N of M" without a reset +# cadence. +_COUNTER_RESET_PERIODS: dict[str, str] = { + "tailored_applications": "monthly", + "premium_applications": "monthly", + "resume_builder_sessions": "lifetime_or_monthly", + "assistant_turns": "monthly", + "resume_parses": "monthly", + "job_searches": "monthly", + "saved_jobs": "persistent", + "saved_workspaces": "persistent", +} + + +# Counters that resolve to a lifetime period_key on Free tier and +# monthly on Pro / Business. The resume_builder_sessions gate inside +# its own service uses the same flag. We mirror that here so the +# /workspace/quota snapshot reads from the same row the gate writes. +_LIFETIME_ON_FREE: frozenset[str] = frozenset({"resume_builder_sessions"}) + + +# Counters that are NOT period-keyed -- they track row counts in +# their own persistence store (saved_jobs / saved_workspaces tables). +# These bypass `quota.read_counter` entirely. +_PERSISTENT_COUNTERS: frozenset[str] = frozenset( + {"saved_jobs", "saved_workspaces"} +) + + +def _reset_period_label(tier: Tier, counter_name: str) -> str: + """Resolve the canonical reset-period string for a tier/counter + pair. The frontend uses this to render "resets monthly" / + "lifetime quota" / "persistent storage" copy below each + indicator. + + Lifetime-on-Free counters get "lifetime" on Free and "monthly" + on Pro / Business; persistent counters always return "persistent" + regardless of tier; everything else is monthly. + """ + raw_label = _COUNTER_RESET_PERIODS.get(counter_name, "monthly") + if raw_label == "lifetime_or_monthly": + return "lifetime" if tier == "free" else "monthly" + return raw_label + + +def _is_lifetime_for_tier(tier: Tier, counter_name: str) -> bool: + """Whether the period_key for this counter should be "lifetime" + rather than the YYYY-MM partition. Mirrors the same decision the + resume_builder_sessions gate makes inside its own service so the + quota snapshot reads from the same row the gate writes.""" + if counter_name not in _LIFETIME_ON_FREE: + return False + return tier == "free" + + +def _build_counter_snapshot( + *, + counter_name: str, + current: int, + cap: int, + reset_period: str, +) -> dict[str, Any]: + """Pack a single counter's state into the UI-facing shape. + + UNLIMITED (-1) is surfaced as cap=-1 / remaining=-1 so the + frontend's `cap < 0` check renders an "Unlimited" pill without + needing a separate flag. `current` for an UNLIMITED counter is + always 0 because we never write a row for one (see + `quota.read_counter`'s short-circuit). + """ + if cap == UNLIMITED: + remaining = UNLIMITED + else: + remaining = max(cap - current, 0) + return { + "current": int(current), + "limit": int(cap), + "remaining": int(remaining), + "reset_period": reset_period, + } + + +def _persistent_count( + *, + counter_name: str, + auth_context, + access_token: str, + refresh_token: str, + cap: int, +) -> int: + """Read the row count for a persistent counter (saved_jobs / + saved_workspaces) from its store. Returns 0 when the cap is + UNLIMITED (we never need the number) or when the store isn't + configured (local-dev without Supabase). Best-effort: an outage + in the store shouldn't break the /workspace/quota response.""" + if cap == UNLIMITED: + return 0 + user_id = str(getattr(auth_context.app_user, "id", "") or "") + if not user_id: + return 0 + auth_service = auth_context.auth_service + if counter_name == "saved_jobs": + store = SavedJobsStore(auth_service) + if not store.is_configured(): + return 0 + try: + # +1 over cap so a runaway state still produces an + # accurate "at-or-over" indicator instead of clipping + # at the cap. + rows = store.list_jobs( + access_token, + refresh_token, + user_id, + limit=cap + 1, + ) + except Exception: # noqa: BLE001 - read is best-effort + return 0 + return len(rows) + if counter_name == "saved_workspaces": + store = SavedWorkspaceStore(auth_service) + if not store.is_configured(): + return 0 + try: + record, status = store.load_workspace( + access_token, + refresh_token, + user_id, + ) + except Exception: # noqa: BLE001 - read is best-effort + return 0 + # The store currently upserts on user_id so the row count is + # always 0 or 1; we mirror the gate's behavior in + # workspace_persistence_service which checks the same + # "available" status. + return 1 if status == "available" and record is not None else 0 + return 0 + + +def get_workspace_quota_snapshot( + *, + access_token: str, + refresh_token: str, +) -> dict[str, Any]: + """Build the read-only quota snapshot for /workspace/quota. + + Anonymous callers (no auth tokens) raise AuthRequiredError so the + route can surface a clean 401 — the snapshot only makes sense for + an authenticated user. The route's exception handler turns the + AppError into a 400; if we ever want a true 401 path we'd update + the handler in lock-step with the route registration. + + The snapshot covers all eight counters from `TIER_CAPS` for the + current tier. `premium_available` is True when the tier's + `premium_applications` cap is > 0; the frontend reads this to + decide whether the Premium toggle renders enabled or in the + "Upgrade to unlock" disabled state. + """ + if not (access_token and refresh_token): + raise WorkspaceQuotaAuthRequired( + "Sign in to view your workspace quota." + ) + + try: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + except AppError as exc: + # Token validation failed -- surface as auth required so the + # frontend's 401 handler can prompt re-auth without rendering + # a stale quota snapshot. + raise WorkspaceQuotaAuthRequired( + "Your session has expired. Sign in again to view quota." + ) from exc + + tier = resolve_user_tier(auth_context.app_user) + caps = TIER_CAPS[tier] + quota_user_id = str(getattr(auth_context.app_user, "id", "") or "") + + counters: dict[str, dict[str, Any]] = {} + for counter_name, cap in caps.items(): + if counter_name in _PERSISTENT_COUNTERS: + current = _persistent_count( + counter_name=counter_name, + auth_context=auth_context, + access_token=access_token, + refresh_token=refresh_token, + cap=cap, + ) + elif quota_user_id: + current = quota.read_counter( + counter_name, + quota_user_id, + tier, + lifetime=_is_lifetime_for_tier(tier, counter_name), + ) + else: + current = 0 + counters[counter_name] = _build_counter_snapshot( + counter_name=counter_name, + current=current, + cap=cap, + reset_period=_reset_period_label(tier, counter_name), + ) + + return { + "tier": tier, + "counters": counters, + # Premium is "available" when the tier's premium_applications + # cap is non-zero. Free has cap=0 so the toggle renders + # disabled with an "Upgrade to Pro" tooltip; Pro/Business + # both have non-zero caps and surface premium=True. + "premium_available": caps["premium_applications"] > 0, + "period_start": _period_start_iso(), + "upgrade_url": quota.UPGRADE_URL, + } + + +def _period_start_iso() -> str: + """First-of-month UTC date for the current monthly partition. + + Surfaced so the UI can render "resets on X" copy. Format is YYYY-MM-DD; + callers parse it with Date.parse on the frontend. The frontend + could derive this from period_key but having it pre-computed + keeps the parsing surface uniform. + """ + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + return f"{now.year:04d}-{now.month:02d}-01" + + +__all__ = [ + "WorkspaceQuotaAuthRequired", + "get_workspace_quota_snapshot", +] diff --git a/backend/services/workspace_run_jobs.py b/backend/services/workspace_run_jobs.py new file mode 100644 index 0000000..eb9fec4 --- /dev/null +++ b/backend/services/workspace_run_jobs.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +from src.errors import AppError +from src.logging_utils import get_logger, log_event + +from backend.services.workspace_service import run_workspace_analysis + + +JOB_TTL_SECONDS = 60 * 30 +# One uvicorn worker per the VPS deployment; the semaphore protects that +# single process from runaway thread spawns under burst /analyze-jobs +# traffic. A simultaneously-running agentic workflow holds an LLM client +# + parsed snapshots in memory, and 5 is comfortably below where a 1-2GB +# container starts feeling pressure. +JOB_CONCURRENCY_LIMIT = 5 +JOB_RETRY_AFTER_SECONDS = 30 +LOGGER = get_logger(__name__) + + +class WorkspaceRunJobCapacityError(RuntimeError): + """Raised when `_RUN_SEMAPHORE` is exhausted at request time.""" + + +@dataclass +class WorkspaceRunJob: + job_id: str + status: str = "queued" + stage_title: str | None = "Workflow crew" + stage_detail: str | None = "Opening your application brief and preparing the first agent." + progress_percent: int = 3 + result: dict[str, Any] | None = None + error_message: str | None = None + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + +_JOBS: dict[str, WorkspaceRunJob] = {} +_LOCK = threading.Lock() +_RUN_SEMAPHORE = threading.BoundedSemaphore(JOB_CONCURRENCY_LIMIT) + + +def _prune_jobs() -> None: + cutoff = time.time() - JOB_TTL_SECONDS + stale_job_ids = [job_id for job_id, job in _JOBS.items() if job.updated_at < cutoff] + for job_id in stale_job_ids: + _JOBS.pop(job_id, None) + + +def _serialize_job(job: WorkspaceRunJob) -> dict[str, Any]: + return { + "job_id": job.job_id, + "status": job.status, + "stage_title": job.stage_title, + "stage_detail": job.stage_detail, + "progress_percent": int(job.progress_percent), + "result": job.result, + "error_message": job.error_message, + } + + +def _update_job_progress(job_id: str, title: str, detail: str, value: int) -> None: + with _LOCK: + job = _JOBS.get(job_id) + if job is None: + return + job.status = "running" + job.stage_title = title + job.stage_detail = detail + job.progress_percent = max(0, min(100, int(value))) + job.updated_at = time.time() + + +def _run_job( + *, + job_id: str, + resume_text: str, + resume_filetype: str, + resume_source: str, + job_description_text: str, + imported_job_posting: dict[str, Any] | None, + premium: bool, + access_token: str, + refresh_token: str, +) -> None: + try: + try: + result = run_workspace_analysis( + resume_text=resume_text, + resume_filetype=resume_filetype, + resume_source=resume_source, + job_description_text=job_description_text, + imported_job_posting=imported_job_posting, + run_assisted=True, + premium=premium, + access_token=access_token, + refresh_token=refresh_token, + progress_callback=lambda title, detail, value: _update_job_progress( + job_id, + title, + detail, + value, + ), + ) + with _LOCK: + job = _JOBS.get(job_id) + if job is None: + return + job.status = "completed" + job.result = result + job.progress_percent = 100 + job.stage_title = "Workflow crew" + job.stage_detail = "All agents are done. Your tailored documents are ready to review." + job.updated_at = time.time() + except AppError as error: + message = error.user_message + log_event( + LOGGER, + 30, + "workspace_run_job_failed", + "The background workspace analysis job failed with an application error.", + job_id=job_id, + error_type=type(error).__name__, + message=message, + ) + with _LOCK: + job = _JOBS.get(job_id) + if job is None: + return + job.status = "failed" + job.error_message = message + job.updated_at = time.time() + except Exception as error: # pragma: no cover - defensive server fallback + LOGGER.exception("Background workspace analysis job crashed.", extra={"job_id": job_id}) + with _LOCK: + job = _JOBS.get(job_id) + if job is None: + return + job.status = "failed" + job.error_message = str(error) or "The agentic workflow failed unexpectedly." + job.updated_at = time.time() + finally: + # Release the slot regardless of how the worker exits, so a + # crash never permanently shrinks the cap below the limit. + _RUN_SEMAPHORE.release() + + +def start_workspace_analysis_job( + *, + resume_text: str, + resume_filetype: str, + resume_source: str, + job_description_text: str, + imported_job_posting: dict[str, Any] | None, + premium: bool = False, + access_token: str, + refresh_token: str, +) -> dict[str, Any]: + # Non-blocking acquire so a saturated server fast-fails the request + # rather than queueing it behind an opaque thread-spawn delay. The + # matching release lives in `_run_job`'s finally block. + if not _RUN_SEMAPHORE.acquire(blocking=False): + raise WorkspaceRunJobCapacityError( + "Too many agentic workflow runs are in flight right now." + ) + + try: + with _LOCK: + _prune_jobs() + job_id = uuid.uuid4().hex + job = WorkspaceRunJob(job_id=job_id) + _JOBS[job_id] = job + + worker = threading.Thread( + target=_run_job, + kwargs={ + "job_id": job_id, + "resume_text": resume_text, + "resume_filetype": resume_filetype, + "resume_source": resume_source, + "job_description_text": job_description_text, + "imported_job_posting": imported_job_posting, + "premium": premium, + "access_token": access_token, + "refresh_token": refresh_token, + }, + daemon=True, + ) + worker.start() + except BaseException: + # Thread spawn (or anything else above) failed before `_run_job` + # could run; release the slot ourselves so capacity isn't lost. + _RUN_SEMAPHORE.release() + with _LOCK: + _JOBS.pop(job_id, None) + raise + + with _LOCK: + return _serialize_job(_JOBS[job_id]) + + +def get_workspace_analysis_job(job_id: str) -> dict[str, Any] | None: + with _LOCK: + _prune_jobs() + job = _JOBS.get(job_id) + if job is None: + return None + return _serialize_job(job) diff --git a/backend/services/workspace_service.py b/backend/services/workspace_service.py new file mode 100644 index 0000000..e3adb47 --- /dev/null +++ b/backend/services/workspace_service.py @@ -0,0 +1,870 @@ +from __future__ import annotations + +import base64 +import json +import logging +from dataclasses import asdict, is_dataclass +from io import BytesIO +from types import SimpleNamespace +from typing import Any, Iterable + +from src.agents.orchestrator import ApplicationOrchestrator +from src.assistant_service import AssistantService +from src.errors import AppError +from src.logging_utils import get_logger, log_event +from src.cover_letter_builder import build_cover_letter_artifact +from src.config import assisted_workflow_requires_login +from src.errors import InputValidationError +from src.openai_service import OpenAIService +from src.parsers.jd import parse_jd_text +from src.parsers.resume import parse_resume_document +from src.resume_builder import build_tailored_resume_artifact +from src.schemas import AssistantResponse, ResumeDocument +from src.services.fit_service import build_fit_analysis +from src.services.jd_summary_service import generate_job_summary_view +from src.services.job_service import ( + build_job_description_from_text, + build_job_description_from_text_auto, +) +from src.services.profile_service import ( + build_candidate_profile_from_resume_auto, +) +from src.services.tailoring_service import build_tailored_resume_draft +from backend import quota +from backend.model_routing import build_workflow_model_overrides +from backend.services.auth_session_service import ( + build_openai_service_for_context, + resolve_authenticated_context, +) +from backend.tiers import resolve_user_tier + + +_QUOTA_LOGGER = get_logger("backend.services.workspace_service.quota") + + +class _InMemoryUploadedFile(BytesIO): + def __init__(self, *, file_bytes: bytes, filename: str, mime_type: str): + super().__init__(file_bytes) + self.name = filename + self.type = mime_type + + +def _decode_base64_content(content_base64: str) -> bytes: + try: + return base64.b64decode(str(content_base64 or "").encode("utf-8"), validate=True) + except Exception as exc: + raise InputValidationError("The uploaded file could not be decoded safely.") from exc + + +def _namespace_value(value: Any): + if isinstance(value, dict): + return SimpleNamespace(**{key: _namespace_value(item) for key, item in value.items()}) + if isinstance(value, list): + return [_namespace_value(item) for item in value] + return value + + +def _serialize(value: Any): + if is_dataclass(value): + return {key: _serialize(item) for key, item in asdict(value).items()} + if isinstance(value, dict): + return {key: _serialize(item) for key, item in value.items()} + if isinstance(value, list): + return [_serialize(item) for item in value] + return value + + +def _build_resume_document(*, resume_text: str, resume_filetype: str, resume_source: str): + normalized_text = str(resume_text or "").strip() + if not normalized_text: + raise InputValidationError("Add a resume before running workspace analysis.") + return ResumeDocument( + text=normalized_text, + filetype=str(resume_filetype or "TXT").strip() or "TXT", + source=str(resume_source or "workspace").strip() or "workspace", + ) + + +def _enrich_job_description_from_imported_posting(job_description, imported_job_posting: dict[str, Any] | None): + if not imported_job_posting: + return job_description + + imported_title = str(imported_job_posting.get("title", "") or "").strip() + imported_location = str(imported_job_posting.get("location", "") or "").strip() + + if imported_title: + job_description.title = imported_title + + if imported_location: + job_description.location = imported_location + + return job_description + + +def parse_resume_upload( + *, + filename: str, + mime_type: str, + content_base64: str, + access_token: str = "", + refresh_token: str = "", +): + """Parse a user-uploaded resume. + + Quota gate (Step 5 of tier-enforcement): + * `resume_parses` is monthly: Free 3 / Pro 25 / Business 100. + * Gate runs BEFORE `parse_resume_document` so we don't burn + the parse if we'd just reject. Refund-on-failure pattern + mirrors `run_workspace_analysis` -- a parser exception + rolls the credit back so a corrupted PDF doesn't cost the + user a credit. + * Anonymous uploads (no auth tokens) skip the gate. The + existing rate-limit on the route still bounds abuse for + unauthenticated traffic. + + Note (deferred enhancement): the original brief mentioned a + "5 lifetime grace + 3/month" structure for Free tier. That's + a UX nicety that requires a second counter + (resume_parses_lifetime) and a layered gate; this PR ships + the simpler 3/25/100 monthly form and defers the grace + window. If real-world Free users hit the 3/mo wall on first + signup we can wire the lifetime grace as a follow-up without + touching call sites -- just add the second counter check. + """ + auth_context = None + if access_token and refresh_token: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + + app_user = getattr(auth_context, "app_user", None) if auth_context is not None else None + tier = resolve_user_tier(app_user) + quota_user_id = str(getattr(app_user, "id", "") or "") if app_user is not None else "" + quota_consumed = False + if quota_user_id: + quota.check_and_increment("resume_parses", quota_user_id, tier) + quota_consumed = True + + try: + uploaded_file = _InMemoryUploadedFile( + file_bytes=_decode_base64_content(content_base64), + filename=filename, + mime_type=mime_type, + ) + resume_document = parse_resume_document(uploaded_file, source=f"workspace:{filename}") + candidate_profile = build_candidate_profile_from_resume_auto(resume_document) + return { + "resume_document": _serialize(resume_document), + "candidate_profile": _serialize(candidate_profile), + } + except BaseException: + # Refund-on-failure: if the parse blew up (corrupted file, + # OCR timeout, etc.) roll back the credit so the user gets + # another shot. Best-effort -- a refund failure logs but + # doesn't mask the original parsing exception. + if quota_consumed: + try: + quota.refund("resume_parses", quota_user_id, tier) + except Exception: # noqa: BLE001 - refund is best-effort + log_event( + _QUOTA_LOGGER, + logging.WARNING, + "resume_parse_quota_refund_failed", + "Refund after resume parse failure raised; user credit " + "was not restored.", + counter="resume_parses", + user_id=quota_user_id, + tier=tier, + ) + raise + + +def parse_job_description_upload(*, filename: str, mime_type: str, content_base64: str): + uploaded_file = _InMemoryUploadedFile( + file_bytes=_decode_base64_content(content_base64), + filename=filename, + mime_type=mime_type, + ) + job_description_text = parse_jd_text(uploaded_file) + # Production parsing path: LLM source-of-truth with deterministic + # fallback. Same architecture we use for resume parsing. + job_description = build_job_description_from_text_auto(job_description_text) + jd_summary_view = generate_job_summary_view( + openai_service=OpenAIService(), + job_description=job_description, + imported_job_posting=None, + ) + return { + "job_description_text": job_description_text, + "job_description": _serialize(job_description), + "jd_summary_view": _serialize(jd_summary_view), + } + + +def run_workspace_analysis( + *, + resume_text: str, + resume_filetype: str, + resume_source: str, + job_description_text: str, + imported_job_posting: dict[str, Any] | None, + run_assisted: bool, + premium: bool = False, + access_token: str = "", + refresh_token: str = "", + progress_callback=None, +): + """Build a tailored application bundle for a single (resume, JD) pair. + + Quota gate (Step 3 of tier-enforcement): + * For authenticated users, the gate resolves the user's tier via + `resolve_user_tier` and atomically increments either + `tailored_applications` (premium=False) or + `premium_applications` (premium=True) BEFORE the workflow runs. + Burning the credit up-front means concurrent /workspace/analyze + calls from the same user can't both squeeze past the cap. + * If the workflow then raises, we refund the increment so a + transient orchestrator failure doesn't cost the user a credit. + Refunding only after a successful increment is critical -- if + the increment itself raised QuotaExceededError, no row was + written and the refund must be skipped (the brief calls this + out explicitly). + * For anonymous users the gate is bypassed because there's no + stable user_id to attribute the increment to. The Free-tier + guard against premium=True still fires (we resolve the tier + as "free" for the synthetic anonymous context, and the + QuotaExceededError carries the "Pro+ only" copy) so anonymous + callers can't slip through the premium model surface. + + Anonymous + premium=True is rejected by raising a QuotaExceededError + with cap=0; the FastAPI handler converts it to the standard 429 + payload so the frontend renders the same upgrade nudge. + """ + resume_document = _build_resume_document( + resume_text=resume_text, + resume_filetype=resume_filetype, + resume_source=resume_source, + ) + + auth_context = None + if access_token and refresh_token: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + + # Quota gate. Runs BEFORE expensive work (orchestrator, OpenAI + # calls, artifact rendering) so a rejection is cheap. The gate is + # the ONLY place that decides per-tier limits -- per the brief, + # no scattered `if tier == "free"` checks live downstream. + counter_name = "premium_applications" if premium else "tailored_applications" + tier = resolve_user_tier( + auth_context.app_user if auth_context is not None else None + ) + quota_user_id = ( + auth_context.app_user.id if auth_context is not None else "" + ) + quota_consumed = False + if quota_user_id: + # Authenticated path: real user_id, real Supabase row. Raises + # QuotaExceededError on cap breach; the route's global handler + # converts that to 429 with the canonical payload. + quota.check_and_increment(counter_name, quota_user_id, tier) + quota_consumed = True + elif premium: + # Anonymous + premium=True: resolve tier as free (already + # done) and surface the same Pro+ rejection message a + # signed-in free user would see. We construct the error + # directly rather than calling check_and_increment because + # the helper requires a user_id; the user-facing 429 looks + # identical either way. + from backend.tiers import TIER_CAPS + from src.errors import QuotaExceededError + + raise QuotaExceededError( + "Premium applications are a Pro+ feature. Sign in and upgrade " + "to run premium tailoring for this job.", + counter=counter_name, + current=0, + cap=TIER_CAPS[tier][counter_name], + reset_period=quota.current_period_key(), + tier=tier, + ) + + try: + candidate_profile = build_candidate_profile_from_resume_auto(resume_document) + job_description = _enrich_job_description_from_imported_posting( + build_job_description_from_text_auto(job_description_text), + imported_job_posting, + ) + fit_analysis = build_fit_analysis(candidate_profile, job_description) + tailored_draft = build_tailored_resume_draft( + candidate_profile, + job_description, + fit_analysis, + ) + + openai_service = None + if auth_context is not None: + openai_service, _ = build_openai_service_for_context(auth_context) + + jd_summary_view = generate_job_summary_view( + openai_service=openai_service, + job_description=job_description, + imported_job_posting=imported_job_posting, + ) + + agent_result = None + workflow_mode = "deterministic_preview" + fallback_reason = "" + + if run_assisted: + if auth_context is None and assisted_workflow_requires_login(): + raise InputValidationError( + "Sign in with Google before running the AI-assisted workflow." + ) + if openai_service is None: + openai_service = OpenAIService() + # Tier-aware model selection (Step 7a). The premium flag + # is the source of truth — never autodetect or sniff. The + # gate above already burned a premium_applications credit + # when premium=True for authenticated users, OR rejected + # the request entirely for premium=True + Free (cap=0). + # So if we're here on premium=True, the user genuinely + # has a premium credit being charged AND select_workflow_model + # will resolve to the upgraded model. + # + # For anonymous + premium=False (the deterministic preview + # path) the override map is all-None and the orchestrator + # falls through to the standard task-routed models. No + # behavioral change for that path. + model_overrides = build_workflow_model_overrides( + tier=tier, + premium=bool(premium), + ) + agent_result = ApplicationOrchestrator( + openai_service=openai_service, + model_overrides=model_overrides, + ).run( + candidate_profile, + job_description, + fit_analysis=fit_analysis, + tailored_draft=tailored_draft, + progress_callback=progress_callback, + ) + workflow_mode = agent_result.mode + fallback_reason = agent_result.fallback_reason + + tailored_resume_artifact = build_tailored_resume_artifact( + candidate_profile, + job_description, + fit_analysis, + tailored_draft, + agent_result=agent_result, + ) + cover_letter_artifact = build_cover_letter_artifact( + candidate_profile, + job_description, + fit_analysis, + tailored_draft, + agent_result=agent_result, + ) + + review = getattr(agent_result, "review", None) + + return { + "resume_document": _serialize(resume_document), + "candidate_profile": _serialize(candidate_profile), + "job_description": _serialize(job_description), + "jd_summary_view": _serialize(jd_summary_view), + "fit_analysis": _serialize(fit_analysis), + "tailored_draft": _serialize(tailored_draft), + "agent_result": _serialize(agent_result) if agent_result else None, + "artifacts": { + "tailored_resume": _serialize(tailored_resume_artifact), + "cover_letter": _serialize(cover_letter_artifact), + }, + "workflow": { + "mode": workflow_mode, + "assisted_requested": bool(run_assisted), + "assisted_available": bool(openai_service and openai_service.is_available()), + "review_approved": bool(review.approved) if review else False, + "fallback_reason": fallback_reason, + }, + "imported_job_posting": imported_job_posting, + } + except BaseException: + # Refund on failure. Only runs when the increment actually + # consumed a credit -- if the quota gate above raised + # QuotaExceededError, no row was written and `quota_consumed` + # is still False, so we don't accidentally decrement somebody + # else's count. + # + # Use BaseException so SystemExit / KeyboardInterrupt in tests + # also refund cleanly. The refund itself is best-effort and + # logs on its own internal failure, so the original exception + # is always the one that surfaces. + if quota_consumed: + try: + quota.refund(counter_name, quota_user_id, tier) + except Exception: # noqa: BLE001 - refund is best-effort + log_event( + _QUOTA_LOGGER, + logging.WARNING, + "workspace_quota_refund_failed", + "Refund after workflow failure raised; the user's quota " + "credit was not restored. The original workflow error is " + "the one that will surface to the client.", + counter=counter_name, + user_id=quota_user_id, + tier=tier, + ) + raise + + +def answer_workspace_question( + *, + question: str, + current_page: str, + workspace_state: dict[str, Any] | None = None, + workspace_snapshot: dict[str, Any] | None, + history: list[dict[str, str]] | None, + access_token: str = "", + refresh_token: str = "", +): + """Sync workspace assistant endpoint. + + Quota gate (Step 4 of tier-enforcement): + * For authenticated users we atomically increment + ``assistant_turns`` BEFORE invoking the LLM. The streaming + sibling (`stream_workspace_question`) routes through the same + counter, so a single user mixing /assistant/answer and + /assistant/answer/stream still shares one monthly budget. + * On any generation failure we refund the credit — same pattern + as ``run_workspace_analysis``. A transient OpenAI hiccup + shouldn't burn one of the user's monthly turns. + * Anonymous traffic (no user_id) skips the gate entirely. The + deterministic fallback path inside ``AssistantService`` still + runs, so unauthenticated users still see a useful answer — + they're just not metered against the per-tier monthly cap. + """ + workflow_view_model = None + artifact = None + app_context = { + "is_authenticated": False, + "assistant_requires_login": False, + "resume_upload_requires_login": False, + } + + if workspace_snapshot: + workflow_view_model = SimpleNamespace( + candidate_profile=_namespace_value(workspace_snapshot.get("candidate_profile")), + job_description=_namespace_value(workspace_snapshot.get("job_description")), + fit_analysis=_namespace_value(workspace_snapshot.get("fit_analysis")), + tailored_draft=_namespace_value(workspace_snapshot.get("tailored_draft")), + agent_result=_namespace_value(workspace_snapshot.get("agent_result")), + ) + artifacts = dict(workspace_snapshot.get("artifacts") or {}) + artifact = _namespace_value(artifacts.get("tailored_resume")) + app_context.update( + { + "has_resume": bool(workspace_snapshot.get("candidate_profile")), + "has_job_description": bool(workspace_snapshot.get("job_description")), + "has_tailored_resume": artifact is not None, + "has_cover_letter": bool(artifacts.get("cover_letter")), + } + ) + + # Merge the live workspace-state projection (small, sent every + # turn) so the LLM sees pre-analysis context too. workspace_state + # is authoritative for has_resume/has_jd flags — the snapshot + # block above only fires when an analysis has run, but the user + # might have a parsed resume + JD without having run analysis. + if workspace_state: + app_context["workspace_state"] = workspace_state + app_context.setdefault("has_resume", bool(workspace_state.get("has_resume"))) + app_context.setdefault( + "has_job_description", bool(workspace_state.get("has_jd")) + ) + # If snapshot didn't fire above but workspace_state says so, + # still expose the flags (overriding the False defaults). + if workspace_state.get("has_resume"): + app_context["has_resume"] = True + if workspace_state.get("has_jd"): + app_context["has_job_description"] = True + + compact_history = [ + SimpleNamespace( + question=str(item.get("question", "") or "").strip(), + response=SimpleNamespace(answer=str(item.get("answer", "") or "").strip()), + ) + for item in list(history or []) + if str(item.get("question", "") or "").strip() + and str(item.get("answer", "") or "").strip() + ] + + auth_context = None + if access_token and refresh_token: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + + # Quota gate. assistant_turns is monthly: 20 / 150 / 500 across + # tiers. The gate routes through `resolve_user_tier` so anonymous + # callers are skipped (no user_id → no row to bill). The refund + # on failure mirrors `run_workspace_analysis`: an OpenAI / parser + # error mid-answer rolls back so the user doesn't lose a credit. + # + # `app_user` is pulled via getattr so older test stubs that return + # a plain dict from resolve_authenticated_context still work — the + # absence of an .app_user attribute is treated identically to the + # anonymous case (no credit consumed). + app_user = getattr(auth_context, "app_user", None) if auth_context is not None else None + tier = resolve_user_tier(app_user) + quota_user_id = str(getattr(app_user, "id", "") or "") if app_user is not None else "" + quota_consumed = False + if quota_user_id: + quota.check_and_increment("assistant_turns", quota_user_id, tier) + quota_consumed = True + + openai_service = None + if auth_context is not None: + openai_service, _ = build_openai_service_for_context(auth_context) + + try: + response: AssistantResponse = AssistantService(openai_service=openai_service).answer( + question, + current_page=current_page, + workflow_view_model=workflow_view_model, + artifact=artifact, + history=compact_history, + app_context=app_context, + ) + return _serialize(response) + except BaseException: + # Refund-on-failure for the same reason as run_workspace_analysis: + # the credit was incremented before the LLM ran, so a transient + # generation error shouldn't cost the user a turn. Only refund + # when the increment above actually consumed a credit; if the + # gate itself raised, `quota_consumed` is still False and no + # decrement is needed. + if quota_consumed: + try: + quota.refund("assistant_turns", quota_user_id, tier) + except Exception: # noqa: BLE001 - refund is best-effort + log_event( + _QUOTA_LOGGER, + logging.WARNING, + "assistant_quota_refund_failed", + "Refund after sync assistant failure raised; user credit " + "was not restored. The original error is the one that " + "will surface to the client.", + counter="assistant_turns", + user_id=quota_user_id, + tier=tier, + ) + raise + + +_STREAM_LOGGER = get_logger("backend.services.workspace_service.stream") + + +def _sse_event(event: str, data: dict[str, Any]) -> str: + """Format a Server-Sent Events frame. + + SSE expects event/data lines terminated by ``\\n`` and the frame + delimited by a blank line (``\\n\\n``). The frontend splits on the + blank-line delimiter, so emitting it consistently here matters. + """ + return f"event: {event}\ndata: {json.dumps(data)}\n\n" + + +def _compute_assistant_sources( + *, + current_page: str | None, + workspace_snapshot: dict[str, Any] | None, +) -> list[str]: + """Pick the page/artifact labels that this answer can plausibly + reference, based on the snapshot alone. + + Streaming pushes a ``meta`` event with sources before the LLM has + produced any text, so sources have to be deterministic — the + non-streaming path used to ask the LLM to choose them. The list + here mirrors the labels the deterministic fallback responses use + in ``AssistantService._fallback_*``, capped at 4 to match the + non-streaming response shape. + """ + sources: list[str] = [] + page_label = str(current_page or "").strip() + if page_label: + sources.append(page_label) + snapshot = workspace_snapshot or {} + artifacts = (snapshot.get("artifacts") or {}) if isinstance(snapshot, dict) else {} + if isinstance(snapshot, dict): + if snapshot.get("candidate_profile"): + sources.append("Upload Resume") + if snapshot.get("job_description"): + sources.append("Manual JD Input") + if snapshot.get("fit_analysis"): + sources.append("Readiness Snapshot") + if isinstance(artifacts, dict): + if artifacts.get("tailored_resume"): + sources.append("Tailored Resume Draft") + if artifacts.get("cover_letter"): + sources.append("Cover Letter") + seen: set[str] = set() + deduped: list[str] = [] + for label in sources: + if label in seen: + continue + seen.add(label) + deduped.append(label) + return deduped[:4] + + +def prepare_stream_workspace_question( + *, + access_token: str = "", + refresh_token: str = "", +): + """Run auth + the assistant_turns quota gate BEFORE the generator starts. + + Returns a dict carrying the resolved openai_service and the bits the + generator needs for refund-on-failure (counter name, user_id, tier, + quota_consumed flag). + + The route MUST call this before constructing + ``stream_workspace_question(...)``: because the streaming function + contains ``yield``, calling it does not execute its body until + iteration starts, and by then ``StreamingResponse`` has already + committed the response status (200) and headers. A + ``QuotaExceededError`` raised inside the generator body would be + silently turned into a 500 mid-stream by Starlette, NOT routed + through our global 429 handler. Doing the gate out-of-band keeps + the rejection surface uniform with the sync sibling. + """ + auth_context = None + if access_token and refresh_token: + try: + auth_context = resolve_authenticated_context( + access_token=access_token, + refresh_token=refresh_token, + ) + except AppError as auth_exc: + log_event( + _STREAM_LOGGER, + logging.WARNING, + "assistant_stream_auth_failed", + "Auth resolution failed for streaming assistant — falling back to anonymous deterministic path.", + error_message=auth_exc.user_message, + details=auth_exc.details, + ) + auth_context = None + + # Pull `app_user` via getattr so callers that hand us a duck-typed + # auth context (older test stubs return a plain dict from + # resolve_authenticated_context) don't crash on the attribute + # access. Anonymous paths surface as `None` here, which + # resolve_user_tier already accepts. + app_user = getattr(auth_context, "app_user", None) if auth_context is not None else None + tier = resolve_user_tier(app_user) + quota_user_id = str(getattr(app_user, "id", "") or "") if app_user is not None else "" + quota_consumed = False + if quota_user_id: + # Raises QuotaExceededError on cap breach. Because we run BEFORE + # the generator yields its first frame, the exception surfaces + # at the route call site (the request is still pre-stream) and + # backend.app's global handler builds the canonical 429. + quota.check_and_increment("assistant_turns", quota_user_id, tier) + quota_consumed = True + + openai_service = None + if auth_context is not None: + try: + openai_service, _ = build_openai_service_for_context(auth_context) + except AppError as auth_exc: + log_event( + _STREAM_LOGGER, + logging.WARNING, + "assistant_stream_openai_init_failed", + "OpenAI service init failed for streaming assistant — falling back to deterministic path.", + error_message=auth_exc.user_message, + details=auth_exc.details, + ) + openai_service = None + + return { + "openai_service": openai_service, + "tier": tier, + "quota_user_id": quota_user_id, + "quota_consumed": quota_consumed, + } + + +def stream_workspace_question( + *, + question: str, + current_page: str, + workspace_state: dict[str, Any] | None = None, + workspace_snapshot: dict[str, Any] | None, + history: list[dict[str, str]] | None, + prepared, +) -> Iterable[str]: + """Generator yielding SSE frames for the assistant streaming + endpoint. + + Event order on the happy path: ``meta`` → ``delta``... → ``done``. + + On error after ``meta`` has been emitted: ``error`` → ``done``. The + frontend is expected to close the stream on either ``done`` or + ``error``. + + ``prepared`` must be the dict returned by + ``prepare_stream_workspace_question``; the route calls that first so + a quota rejection raises pre-stream and lands in the global 429 + handler instead of polluting an in-flight SSE stream. + + A ``followups`` event used to sit between the last ``delta`` and + ``done``, but the suggested-follow-up panel was removed from the + UI as a deliberate product call (commit 9138ead) and the wire + event was dead code in both directions, so it was dropped here. + Re-add it alongside any UI re-introduction. + """ + openai_service = prepared.get("openai_service") + tier = prepared.get("tier", "free") + quota_user_id = str(prepared.get("quota_user_id", "") or "") + quota_consumed = bool(prepared.get("quota_consumed", False)) + + workflow_view_model = None + artifact = None + app_context: dict[str, Any] = { + "is_authenticated": False, + "assistant_requires_login": False, + "resume_upload_requires_login": False, + } + + if workspace_snapshot: + workflow_view_model = SimpleNamespace( + candidate_profile=_namespace_value(workspace_snapshot.get("candidate_profile")), + job_description=_namespace_value(workspace_snapshot.get("job_description")), + fit_analysis=_namespace_value(workspace_snapshot.get("fit_analysis")), + tailored_draft=_namespace_value(workspace_snapshot.get("tailored_draft")), + agent_result=_namespace_value(workspace_snapshot.get("agent_result")), + ) + artifacts = dict(workspace_snapshot.get("artifacts") or {}) + artifact = _namespace_value(artifacts.get("tailored_resume")) + app_context.update( + { + "has_resume": bool(workspace_snapshot.get("candidate_profile")), + "has_job_description": bool(workspace_snapshot.get("job_description")), + "has_tailored_resume": artifact is not None, + "has_cover_letter": bool(artifacts.get("cover_letter")), + } + ) + + # Same merge as in answer_workspace_question — fold the live + # workspace-state projection into app_context so the LLM's + # system prompt sees pre-analysis state too. + if workspace_state: + app_context["workspace_state"] = workspace_state + if workspace_state.get("has_resume"): + app_context["has_resume"] = True + if workspace_state.get("has_jd"): + app_context["has_job_description"] = True + + compact_history = [ + SimpleNamespace( + question=str(item.get("question", "") or "").strip(), + response=SimpleNamespace(answer=str(item.get("answer", "") or "").strip()), + ) + for item in list(history or []) + if str(item.get("question", "") or "").strip() + and str(item.get("answer", "") or "").strip() + ] + + sources = _compute_assistant_sources( + current_page=current_page, + workspace_snapshot=workspace_snapshot, + ) + + # Emit `meta` first so the UI can render the source chip row + # before any answer text starts arriving. + yield _sse_event("meta", {"sources": sources}) + + assistant = AssistantService(openai_service=openai_service) + stream_raised = False + try: + produced_any = False + for chunk in assistant.stream_answer( + question, + current_page=current_page, + workflow_view_model=workflow_view_model, + artifact=artifact, + history=compact_history, + app_context=app_context, + ): + text = str(chunk or "") + if not text: + continue + produced_any = True + yield _sse_event("delta", {"text": text}) + if not produced_any: + # Even the deterministic fallback produced nothing — emit a + # safe note so the UI doesn't render an empty bubble. + yield _sse_event( + "delta", + { + "text": ( + "The assistant could not produce a response just now. " + "Please try again or rephrase the question." + ) + }, + ) + except AppError as exc: + stream_raised = True + log_event( + _STREAM_LOGGER, + logging.WARNING, + "assistant_stream_app_error", + "Streaming assistant raised an AppError.", + error_message=exc.user_message, + details=exc.details, + ) + yield _sse_event("error", {"detail": exc.user_message}) + except Exception as exc: # noqa: BLE001 - boundary for streaming surface + stream_raised = True + log_event( + _STREAM_LOGGER, + logging.ERROR, + "assistant_stream_unexpected_error", + "Streaming assistant raised an unexpected error.", + error_type=type(exc).__name__, + details=str(exc), + ) + yield _sse_event( + "error", + {"detail": "The assistant stream failed. Please try again."}, + ) + finally: + # Refund-on-failure: a generator exception AFTER the credit was + # consumed should not burn the user's monthly turn. The gate + # itself ran outside the generator (see + # `prepare_stream_workspace_question`), so reaching this branch + # always means a successful increment we want to roll back. + if stream_raised and quota_consumed and quota_user_id: + try: + quota.refund("assistant_turns", quota_user_id, tier) + except Exception: # noqa: BLE001 - refund is best-effort + log_event( + _STREAM_LOGGER, + logging.WARNING, + "assistant_stream_quota_refund_failed", + "Refund after streaming assistant failure raised; user " + "credit was not restored.", + counter="assistant_turns", + user_id=quota_user_id, + tier=tier, + ) + yield _sse_event("done", {}) diff --git a/backend/subscriptions.py b/backend/subscriptions.py new file mode 100644 index 0000000..1813e25 --- /dev/null +++ b/backend/subscriptions.py @@ -0,0 +1,423 @@ +"""Subscription lookup + cache for tier resolution. + +`resolve_user_tier` reads from this module on every quota gate, which +means it has to: + + 1. Never block on a network round-trip. The function is called from + hot paths (every /workspace/analyze, every assistant turn, + /workspace/quota). A 200ms Supabase read per call would shred + P95. + 2. Pick up the new tier within a minute of a successful webhook. LS + sends ``subscription_created`` immediately after checkout; the + user expects to see Pro state within "the next page load or two", + not the next refresh-five-minutes-later cycle. + +The compromise is a 60-second TTL cache keyed by +``(user_id, current_minute_bucket)`` — the cache key changes every +calendar minute, so reads converge on the new value within at most +60 seconds without the webhook having to invalidate anything. The LRU +holds at most 4096 entries (≈10MB) and evicts the oldest on overflow. + +The webhook handler in `backend/webhooks/lemonsqueezy.py` does NOT +need to call `invalidate_subscription_cache(user_id)` — it can, for a +sharper user-visible cutover, but the natural minute-bucket expiry is +the contract. + +Backend selection mirrors `backend.quota`: + * Supabase service-role client when SUPABASE_URL + + SUPABASE_SERVICE_ROLE_KEY are present. + * In-memory fallback for unit tests + local dev without Supabase. +""" +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import lru_cache +from typing import Optional + +from src.config import SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL + + +try: # supabase is an optional dep in some test paths + from supabase import create_client as _create_supabase_client # type: ignore +except Exception: # pragma: no cover - defensive import + _create_supabase_client = None # type: ignore + + +logger = logging.getLogger(__name__) + + +# The subscriptions table name is fixed for v1 (mirrors the SQL +# migration in docs/sql/supabase-subscriptions.sql). Exposed as a +# module-level constant so the webhook handler can write to the same +# table without hard-coding the literal twice. +SUBSCRIPTIONS_TABLE = "subscriptions" +WEBHOOK_LOG_TABLE = "subscription_webhook_log" + + +@dataclass(frozen=True) +class Subscription: + """Row shape returned by `get_active_subscription`. + + Mirrors the Supabase table column-for-column. `tier` is narrowed + to "pro" | "business" by the table's check constraint, but typed + as plain str here so the upstream resolver can do its own Literal + narrow without an explicit cast. + """ + + user_id: str + processor: str + processor_customer_id: str + processor_subscription_id: str + tier: str + status: str + current_period_end: Optional[datetime] + cancel_at_period_end: bool + variant_id: str + + +# ─── Backend abstraction ──────────────────────────────────────────────── + + +class _InMemorySubscriptionsBackend: + """Process-local store used in unit tests + local dev without + Supabase. Mirrors the Supabase backend's read surface; the webhook + handler writes through the same `upsert` method. + + Thread-safe via a single lock — concurrency in tests is handled + correctly. Production must run with the Supabase backend. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + # user_id -> Subscription + self._store: dict[str, Subscription] = {} + # event_id -> True; mirrors the subscription_webhook_log table + # for idempotency in tests. + self._processed_events: set[str] = set() + + def reset(self) -> None: + with self._lock: + self._store.clear() + self._processed_events.clear() + + def read_by_user_id(self, user_id: str) -> Optional[Subscription]: + with self._lock: + return self._store.get(user_id) + + def upsert(self, sub: Subscription) -> None: + with self._lock: + self._store[sub.user_id] = sub + + def has_processed_event(self, event_id: str) -> bool: + with self._lock: + return event_id in self._processed_events + + def mark_event_processed(self, event_id: str, event_name: str) -> None: + del event_name # only the event_id is used in-memory. + with self._lock: + self._processed_events.add(event_id) + + +class _SupabaseSubscriptionsBackend: + """Service-role-backed subscription store. + + Reads via the service role so RLS doesn't matter -- the row's + user_id is the lookup key, not auth.uid(). Writes are by the + webhook handler (which also uses service_role) on a different + code path; the read API exposed here is purely for the tier + resolver. + + Lazy client initialization so importing the module without + SUPABASE_URL / SERVICE_ROLE_KEY doesn't crash -- the in-memory + fallback handles that path. is_configured() picks the dispatch. + """ + + def __init__( + self, + *, + supabase_url: str = SUPABASE_URL, + service_role_key: str = SUPABASE_SERVICE_ROLE_KEY, + ) -> None: + self._url = supabase_url + self._key = service_role_key + self._client = None + + def is_configured(self) -> bool: + return bool(self._url and self._key and _create_supabase_client is not None) + + def _require_client(self): + if self._client is None: + self._client = _create_supabase_client(self._url, self._key) + return self._client + + def read_by_user_id(self, user_id: str) -> Optional[Subscription]: + try: + client = self._require_client() + response = ( + client.table(SUBSCRIPTIONS_TABLE) + .select( + "user_id,processor,processor_customer_id," + "processor_subscription_id,tier,status,current_period_end," + "cancel_at_period_end,variant_id" + ) + .eq("user_id", user_id) + .limit(1) + .execute() + ) + except Exception: # noqa: BLE001 - read is best-effort + logger.exception( + "subscription_read_failed user_id=%s", user_id + ) + return None + data = getattr(response, "data", None) or [] + if not data: + return None + row = data[0] if isinstance(data, list) else data + if not isinstance(row, dict): + return None + return _row_to_subscription(row) + + def upsert(self, sub: Subscription) -> None: + client = self._require_client() + payload = { + "user_id": sub.user_id, + "processor": sub.processor, + "processor_customer_id": sub.processor_customer_id or None, + "processor_subscription_id": sub.processor_subscription_id, + "tier": sub.tier, + "status": sub.status, + "current_period_end": ( + sub.current_period_end.isoformat() + if sub.current_period_end is not None + else None + ), + "cancel_at_period_end": sub.cancel_at_period_end, + "variant_id": sub.variant_id or None, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + client.table(SUBSCRIPTIONS_TABLE).upsert( + payload, on_conflict="user_id" + ).execute() + + def has_processed_event(self, event_id: str) -> bool: + try: + client = self._require_client() + response = ( + client.table(WEBHOOK_LOG_TABLE) + .select("event_id") + .eq("event_id", event_id) + .limit(1) + .execute() + ) + except Exception: # noqa: BLE001 - read is best-effort + logger.exception( + "subscription_webhook_log_read_failed event_id=%s", event_id + ) + # Fail-open on idempotency: if we can't tell whether the + # event was processed, let it through. Webhook handlers + # MUST themselves be idempotent (upsert by user_id) so a + # duplicate is harmless; vs. a fail-closed branch silently + # dropping a real event. + return False + data = getattr(response, "data", None) or [] + return bool(data) + + def mark_event_processed(self, event_id: str, event_name: str) -> None: + try: + client = self._require_client() + client.table(WEBHOOK_LOG_TABLE).insert( + {"event_id": event_id, "event_name": event_name} + ).execute() + except Exception: # noqa: BLE001 - best-effort log write + # If the insert races (two concurrent webhook deliveries + # for the same event_id), the PK conflict is benign -- the + # upsert path already ran idempotently. Log + swallow. + logger.warning( + "subscription_webhook_log_insert_failed event_id=%s", + event_id, + ) + + +def _row_to_subscription(row: dict) -> Subscription: + """Convert a Supabase row dict into a frozen Subscription.""" + raw_end = row.get("current_period_end") + current_period_end: Optional[datetime] + if raw_end: + try: + # Supabase returns timestamptz as ISO-8601 with a "+00:00" + # offset; datetime.fromisoformat handles that since 3.11. + current_period_end = datetime.fromisoformat( + str(raw_end).replace("Z", "+00:00") + ) + except ValueError: + current_period_end = None + else: + current_period_end = None + return Subscription( + user_id=str(row.get("user_id") or ""), + processor=str(row.get("processor") or ""), + processor_customer_id=str(row.get("processor_customer_id") or ""), + processor_subscription_id=str(row.get("processor_subscription_id") or ""), + tier=str(row.get("tier") or ""), + status=str(row.get("status") or ""), + current_period_end=current_period_end, + cancel_at_period_end=bool(row.get("cancel_at_period_end") or False), + variant_id=str(row.get("variant_id") or ""), + ) + + +# Module-level singletons. Tests reach in via `reset_in_memory_backend` +# or by monkeypatching `_BACKEND` directly. +_IN_MEMORY_BACKEND = _InMemorySubscriptionsBackend() +_SUPABASE_BACKEND = _SupabaseSubscriptionsBackend() + + +def _select_backend(): + if _SUPABASE_BACKEND.is_configured(): + return _SUPABASE_BACKEND + return _IN_MEMORY_BACKEND + + +def reset_in_memory_backend() -> None: + """Wipe the process-local subscription store. Test-only.""" + _IN_MEMORY_BACKEND.reset() + + +# ─── 60-second LRU cache ──────────────────────────────────────────────── + + +def _current_minute_bucket(now: Optional[datetime] = None) -> str: + """Return a string key that flips once per UTC calendar minute. + + Used as a cache key component so `_cached_read` automatically + misses (and re-reads from Supabase) within at most 60 seconds of + any subscription state change -- without the webhook handler + having to call into the cache directly. The cache also exposes + `invalidate_subscription_cache` for callers who want a sharper + cutover. + """ + moment = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + return f"{moment.year:04d}-{moment.month:02d}-{moment.day:02d}T{moment.hour:02d}:{moment.minute:02d}" + + +# LRU is module-level so it survives across requests. maxsize=4096 is +# enough to hold every active user across a small fleet; if the +# product scales past that we'll need a process-shared cache (Redis +# / memcached). The TTL via minute_bucket keeps stale entries +# bounded. +@lru_cache(maxsize=4096) +def _cached_read(user_id: str, minute_bucket: str) -> Optional[Subscription]: + """LRU-cached read of a subscription row. + + `minute_bucket` is included in the cache key but unused inside + the function -- it exists purely to invalidate the entry on the + next minute boundary. functools.lru_cache hashes positional args, + so two calls with different minute_bucket strings produce + different cache slots. + """ + del minute_bucket # intentionally unused -- it's the TTL knob. + backend = _select_backend() + return backend.read_by_user_id(user_id) + + +def invalidate_subscription_cache(user_id: Optional[str] = None) -> None: + """Drop cached subscription entries. + + Called by the LS webhook handler after a successful upsert so the + next gate check sees the new state immediately rather than + waiting for the minute-bucket flip. Passing `None` clears the + whole cache (used in tests and on process-wide subscription + refreshes). + + `lru_cache` doesn't support per-key eviction directly, so we + clear the whole cache when `user_id` is provided too. The cache + is small and the webhook path is low-volume, so this is fine. + """ + del user_id # signature reserved for a future per-user clear. + _cached_read.cache_clear() + + +# ─── Public API ───────────────────────────────────────────────────────── + + +def get_active_subscription(user_id: str) -> Optional[Subscription]: + """Return the user's subscription row, or None when no row exists. + + The "active" in the function name refers to "the row that exists + for this user", not the status field. The caller (typically + `resolve_user_tier`) is responsible for interpreting the status + + period semantics; this function just looks the row up. + + Caches the result for up to 60 seconds via an LRU keyed by + `(user_id, current_minute_bucket)`. Webhook upserts call + `invalidate_subscription_cache()` for a sharper cutover; even + without that, the cache naturally converges within 60 seconds. + + Returns None when: + * No subscription row exists for this user (Free tier). + * The backend read failed (logged and swallowed). The caller + falls back to Free in that case -- the alternative is to + block paid gates on a transient Supabase outage, which the + product specifically doesn't want. + """ + if not user_id: + return None + return _cached_read(user_id, _current_minute_bucket()) + + +def upsert_subscription(sub: Subscription) -> None: + """Write a subscription row through the active backend. + + Used by the LS webhook handler. The route does its own HMAC + verification + event idempotency check before calling this, so + this function is a thin write-through to the backend. Invalidates + the read cache after a successful write so the next tier resolution + picks up the new state immediately. + """ + backend = _select_backend() + backend.upsert(sub) + invalidate_subscription_cache() + + +def has_processed_event(event_id: str) -> bool: + """Check whether a webhook event_id has already been processed. + + Used by the LS webhook handler for idempotency. LS retries on + non-2xx responses + has at-least-once semantics, so we MUST be + safe under duplicate delivery. Returns False on read failures + (fail-open) -- the upsert by user_id is itself idempotent, so a + duplicate processing pass produces the same final state. + """ + if not event_id: + return False + backend = _select_backend() + return backend.has_processed_event(event_id) + + +def mark_event_processed(event_id: str, event_name: str) -> None: + """Record that a webhook event_id has been processed. + + Best-effort -- a failure here just means a redelivery would + re-process the event. Since the upsert path is itself idempotent, + that's fine. + """ + if not event_id: + return + backend = _select_backend() + backend.mark_event_processed(event_id, event_name) + + +__all__ = [ + "Subscription", + "SUBSCRIPTIONS_TABLE", + "WEBHOOK_LOG_TABLE", + "get_active_subscription", + "has_processed_event", + "invalidate_subscription_cache", + "mark_event_processed", + "reset_in_memory_backend", + "upsert_subscription", +] diff --git a/backend/tiers.py b/backend/tiers.py new file mode 100644 index 0000000..897c829 --- /dev/null +++ b/backend/tiers.py @@ -0,0 +1,247 @@ +"""Tier resolution + cap matrix for AI Job Agent quota gates. + +Single source of truth for which subscription tier an authenticated +user is on and what monthly / lifetime / persistent caps apply at that +tier. Every quota gate (tailored applications, premium applications, +resume-builder sessions, assistant turns, resume parses, job searches, +saved jobs, saved workspaces) reads from `TIER_CAPS` keyed by the tier +returned from `resolve_user_tier`. No other module should re-derive +the tier — that's the whole point of this indirection. + +`resolve_user_tier` consults `backend.subscriptions.get_active_subscription`, +which reads from the Supabase `subscriptions` table populated by the +Lemon Squeezy webhook handler. The read is LRU-cached for up to 60 +seconds (keyed by user_id + current UTC minute) so the gate never +blocks on a network round-trip. The webhook handler invalidates the +cache on every upsert so paid tier access kicks in within "one page +load" of a successful checkout. + +Source of truth for the cap numbers is the landing-page pricing +matrix; if you change a value here, update the pricing UI in the same +PR. Numbers below mirror the locked table from the +`feat/tier-enforcement` brief: + + COUNTER PERIOD FREE PRO BUSINESS + tailored_applications monthly 3 20 80 + premium_applications monthly 0 5 25 + resume_builder_sessions lifetime* 1 3 15 + assistant_turns monthly 20 150 500 + resume_parses monthly 3 25 100 + job_searches monthly 50 ∞ ∞ + saved_jobs persistent 5 1000 ∞ + saved_workspaces persistent 1 5 ∞ + + * Free uses a lifetime counter; Pro and Business reset monthly. + `check_and_increment` accepts a `lifetime=` flag so the call + site, not this table, decides which period_key to use. + +`UNLIMITED` (= -1) marks "no cap"; the quota helper short-circuits +when the cap equals this sentinel rather than performing an increment. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + +from src.schemas import AppUserRecord + + +Tier = Literal["free", "pro", "business"] + + +# Statuses that grant paid tier access while `current_period_end > +# now()`. Mirrors the Lemon Squeezy subscription state machine: +# * "active": fully paid, current_period_end is the next renewal. +# * "cancelled": user clicked cancel but tier access continues until +# the end of the paid period (LS leaves the +# subscription at status='active' on the data +# payload and sets cancel_at_period_end=true; the +# webhook router maps that to status='cancelled' on +# our row so the resolver can branch on it +# explicitly). +# * "past_due": payment retry pending (dunning). LS retries 3x +# over ~14 days. We keep tier access during that +# window so a transient card decline doesn't +# immediately downgrade a paying user. +# "expired" / "paused" / unknown statuses always resolve to Free. +_PAID_STATUSES_DURING_PERIOD: frozenset[str] = frozenset( + {"active", "cancelled", "past_due"} +) +# Tier values we accept from the subscription row. Anything else +# (typo, future tier we haven't shipped yet) resolves to Free +# defensively. +_PAID_TIERS: frozenset[str] = frozenset({"pro", "business"}) + + +# Sentinel for "no cap on this counter at this tier". The +# `check_and_increment` helper does a single `cap < 0` test to decide +# whether to skip the upsert entirely. Using -1 (not None) keeps the +# inner dict uniformly typed as int so static analysis doesn't trip +# on Optional handling at every call site. +UNLIMITED = -1 + + +# Per-tier caps. The outer key is the tier name returned by +# `resolve_user_tier`; the inner key is the counter name passed to +# `check_and_increment(counter_name, ...)`. Counter names are part of +# the on-disk Supabase schema (composite PK column value) — renaming a +# counter is a data migration, not a refactor. +# +# This table holds the FULL counter set even though Step 3 only wires +# tailored_applications + premium_applications. Steps 4-8 just need to +# call into the helper with the right counter name — they don't have +# to touch this table. +TIER_CAPS: dict[Tier, dict[str, int]] = { + "free": { + "tailored_applications": 3, + "premium_applications": 0, + "resume_builder_sessions": 1, + "assistant_turns": 20, + "resume_parses": 3, + "job_searches": 50, + "saved_jobs": 5, + "saved_workspaces": 1, + }, + "pro": { + "tailored_applications": 20, + "premium_applications": 5, + "resume_builder_sessions": 3, + "assistant_turns": 150, + "resume_parses": 25, + "job_searches": UNLIMITED, + "saved_jobs": 1000, + "saved_workspaces": 5, + }, + "business": { + "tailored_applications": 80, + "premium_applications": 25, + "resume_builder_sessions": 15, + "assistant_turns": 500, + "resume_parses": 100, + "job_searches": UNLIMITED, + "saved_jobs": UNLIMITED, + "saved_workspaces": UNLIMITED, + }, +} + + +# Tier-aware retention for saved workspaces (Step 8). Free plans get a +# 7-day rolling retention window; Pro plans get 30 days; Business is +# unbounded. None is the "unbounded" sentinel rather than a large +# integer so callers can do a single `if days is None: continue` test +# without comparing against a "fake infinity" magic number. +# +# These numbers live HERE (not in TIER_CAPS) because retention is a +# duration, not a count -- conflating them would force a second +# TypedDict field whose semantics differ from every other cap. The +# sweeper reads from this table, the brief locks the values: +# +# TIER SAVED_WORKSPACE RETENTION +# free 7 days +# pro 30 days +# business unbounded (None) +# +# If marketing copy on the pricing page changes, update this mapping +# AND `frontend/src/components/landing/pricing.tsx` in the same PR. +_RETENTION_DAYS_BY_TIER: dict[Tier, int | None] = { + "free": 7, + "pro": 30, + "business": None, +} + + +def retention_days_for_tier(tier: Tier) -> int | None: + """Return the saved-workspace retention duration for a tier. + + Returns a positive integer for capped tiers (Free 7, Pro 30) and + ``None`` for unbounded retention (Business). The sweeper treats + None as "skip this row" -- the workspace stays forever until the + user explicitly deletes it. + + Mirrors HelpmateAI's `TIER_LIMITS[tier]["retention_days"]` shape + but lives in a separate mapping because: + * Retention is a duration, not a count, so it doesn't belong + next to the integer caps in TIER_CAPS. + * The unbounded sentinel is `None` here; TIER_CAPS uses -1. + Mixing both in one TypedDict would force every caller to + carry both type-narrowing branches around. + """ + return _RETENTION_DAYS_BY_TIER[tier] + + +def resolve_user_tier(app_user: AppUserRecord | None) -> Tier: + """Resolve the active subscription tier for an authenticated user. + + Consults `backend.subscriptions.get_active_subscription`, which + reads from the Supabase ``subscriptions`` table populated by the + Lemon Squeezy webhook handler. The read is LRU-cached for up to + 60 seconds so this function never blocks on a network round-trip + -- it sits on every quota gate's hot path. + + Tier resolution rules: + + * No app_user (anonymous): "free". + * No subscription row: "free". + * subscription row with status in {"active", "cancelled", + "past_due"} AND current_period_end > now: return the + subscription's tier. "cancelled" still grants access during + the paid period; "past_due" is the LS dunning window. + * Anything else (status="expired" / "paused", current_period_end + in the past, unknown tier value): "free". + + Lazy import of `backend.subscriptions` so circular imports during + test collection don't crash a bare `from backend.tiers import + TIER_CAPS` path that doesn't need subscriptions. + """ + if app_user is None: + return "free" + user_id = getattr(app_user, "id", None) + if not user_id: + return "free" + + # Local import avoids a hard cycle in test collection: anything + # importing `backend.tiers` (e.g. quota.py) doesn't need to drag + # `backend.subscriptions` in if it's never actually resolving a + # user. Same pattern HelpmateAI uses for its tier shim. + from backend.subscriptions import get_active_subscription + + sub = get_active_subscription(str(user_id)) + if sub is None: + return "free" + + if sub.tier not in _PAID_TIERS: + # Defensive: the table has a CHECK constraint that limits + # this to {"pro", "business"}, but if the constraint is ever + # relaxed or a future migration adds a tier we haven't + # shipped frontend support for, fall back to Free rather + # than letting an unrecognized string flow into TIER_CAPS as + # a KeyError at gate-check time. + return "free" + + if sub.status not in _PAID_STATUSES_DURING_PERIOD: + return "free" + + period_end = sub.current_period_end + if period_end is None: + # No period boundary on the row -- conservatively downgrade. + # An active subscription should always have a + # current_period_end set by the webhook; missing values + # indicate a bug we'd rather catch on the free side than the + # paid side. + return "free" + + if period_end <= datetime.now(timezone.utc): + return "free" + + # mypy/pyright: tier is narrowed to "pro" | "business" by the + # `not in _PAID_TIERS` guard above; cast via the Literal return. + return "pro" if sub.tier == "pro" else "business" + + +__all__ = [ + "TIER_CAPS", + "UNLIMITED", + "Tier", + "resolve_user_tier", + "retention_days_for_tier", +] diff --git a/backend/vps/.env.example b/backend/vps/.env.example new file mode 100644 index 0000000..9a73e5b --- /dev/null +++ b/backend/vps/.env.example @@ -0,0 +1,57 @@ +# Public VPS-facing backend domain used by Caddy on the VPS. +AI_JOB_APPLICATION_API_DOMAIN=api.example.com +PORT=8000 +APP_BASE_URL=https://app.example.com +FRONTEND_APP_URL=https://app.example.com +CORS_ALLOWED_ORIGINS=https://example.com,https://app.example.com,https://www.example.com +AUTH_COOKIE_DOMAIN=.example.com +AUTH_COOKIE_SECURE=true +AUTH_COOKIE_SAMESITE=lax + +OPENAI_API_KEY= +OPENAI_MODEL_DEFAULT=gpt-5-mini-2025-08-07 +OPENAI_MODEL_HIGH_TRUST=gpt-5.4 +OPENAI_MODEL_MID_TIER=gpt-5-mini-2025-08-07 +OPENAI_MODEL_PRODUCT_HELP=gpt-5-mini-2025-08-07 +OPENAI_MODEL_APPLICATION_QA=gpt-5.4 +OPENAI_REASONING_DEFAULT=medium +OPENAI_REASONING_HIGH_TRUST=high +OPENAI_REASONING_PROFILE=medium +OPENAI_REASONING_JOB=medium +OPENAI_REASONING_FIT=medium +OPENAI_REASONING_TAILORING=medium +OPENAI_REASONING_STRATEGY=medium +OPENAI_REASONING_REVIEW=high +OPENAI_REASONING_RESUME_GENERATION=high +OPENAI_REASONING_PRODUCT_HELP=medium +OPENAI_REASONING_APPLICATION_QA=high + +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_AUTH_REDIRECT_URL=https://app.example.com/auth/callback +SUPABASE_APP_USERS_TABLE=app_users +SUPABASE_USAGE_EVENTS_TABLE=usage_events +SUPABASE_SAVED_WORKSPACES_TABLE=saved_workspaces +SUPABASE_SAVED_JOBS_TABLE=saved_jobs +SUPABASE_RESUME_BUILDER_SESSIONS_TABLE=resume_builder_sessions +SAVED_WORKSPACE_TTL_HOURS=24 + +AUTH_REQUIRED_FOR_ASSISTED_WORKFLOW=true +AUTH_DEFAULT_PLAN_TIER=free +AUTH_DEFAULT_ACCOUNT_STATUS=active +AUTH_INTERNAL_USER_EMAILS= +FREE_TIER_MAX_CALLS_PER_DAY=12 +FREE_TIER_MAX_TOKENS_PER_DAY=60000 +PAID_TIER_MAX_CALLS_PER_DAY=80 +PAID_TIER_MAX_TOKENS_PER_DAY=400000 + +ENABLE_JOB_SEARCH_BACKEND=true +JOB_BACKEND_BASE_URL=http://127.0.0.1:8000 +GREENHOUSE_BOARD_TOKENS= +LEVER_SITE_NAMES= + +# Example production pairing: +# AI_JOB_APPLICATION_API_DOMAIN=api.your-domain.com +# FRONTEND_APP_URL=https://your-vercel-project.vercel.app +# CORS_ALLOWED_ORIGINS=https://your-vercel-project.vercel.app,https://app.your-domain.com +# AUTH_COOKIE_DOMAIN=.your-domain.com diff --git a/backend/vps/Caddyfile b/backend/vps/Caddyfile new file mode 100644 index 0000000..17402aa --- /dev/null +++ b/backend/vps/Caddyfile @@ -0,0 +1,13 @@ +{$AI_JOB_APPLICATION_API_DOMAIN} { + encode gzip zstd + + reverse_proxy api:8000 { + # Disable response buffering so Server-Sent Events from + # `/api/workspace/assistant/answer/stream` are flushed to the + # browser frame-by-frame instead of being held until the + # upstream response closes. The endpoint also sets + # `X-Accel-Buffering: no`; this directive is the + # belt-and-braces side of that contract. + flush_interval -1 + } +} diff --git a/backend/vps/docker-compose.override.yml b/backend/vps/docker-compose.override.yml new file mode 100644 index 0000000..3db7a92 --- /dev/null +++ b/backend/vps/docker-compose.override.yml @@ -0,0 +1,10 @@ +services: + api: + container_name: ai-job-application-agent-api + networks: + - shared_ingress + +networks: + shared_ingress: + external: true + name: vps_default diff --git a/backend/vps/docker-compose.yml b/backend/vps/docker-compose.yml new file mode 100644 index 0000000..040c32e --- /dev/null +++ b/backend/vps/docker-compose.yml @@ -0,0 +1,35 @@ +services: + api: + image: ghcr.io/leanderantony/ai_job_application_agent/api:latest + container_name: ai-job-application-agent-api + restart: unless-stopped + env_file: + - .env + environment: + PORT: 8000 + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + caddy: + image: caddy:2 + container_name: ai-job-application-agent-caddy + restart: unless-stopped + depends_on: + - api + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/backend/webhooks/__init__.py b/backend/webhooks/__init__.py new file mode 100644 index 0000000..ae307ed --- /dev/null +++ b/backend/webhooks/__init__.py @@ -0,0 +1,7 @@ +"""Webhook handlers for external payment / event sources. + +Currently houses the Lemon Squeezy subscription webhook. Future +processors (Stripe, Razorpay) would land in sibling modules; the +common shape is "HMAC verify -> idempotency check -> event-to-state +mapping -> 2xx response". +""" diff --git a/backend/webhooks/lemonsqueezy.py b/backend/webhooks/lemonsqueezy.py new file mode 100644 index 0000000..dccd8ad --- /dev/null +++ b/backend/webhooks/lemonsqueezy.py @@ -0,0 +1,523 @@ +"""Lemon Squeezy subscription webhook handler. + +The single entry point is `process_webhook(*, raw_body, signature)`, +which: + + 1. Verifies the X-Signature header (hex-encoded HMAC-SHA256 of the + raw body, signed with AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET). + Constant-time compare via `hmac.compare_digest`. Failure + surfaces as `InvalidWebhookSignature` -- the FastAPI route + converts that to a 401. + 2. Parses the LS event envelope. Unknown / unparseable payloads + log + return early (200 to LS so it doesn't retry). + 3. Checks the idempotency log via + `backend.subscriptions.has_processed_event`. Repeated deliveries + for the same event_id are a no-op (LS retries on non-2xx and + has at-least-once semantics). + 4. Maps the event_name to a subscription state via + `_apply_event(...)`. Subscription tier is derived from the LS + variant_id (env vars + AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_PRO / + _VARIANT_BUSINESS); unknown variant logs + returns early. + 5. Calls `backend.subscriptions.upsert_subscription(...)` and + `mark_event_processed(...)`. + +The route handler in `backend/routers/billing.py` wraps this with +FastAPI plumbing. Tests in tests/backend/test_lemonsqueezy_webhook.py +exercise each event type's state transition + signature failures. + +Event mapping (locked by the brief; matches LS docs): + + EVENT NAME OUR STATUS NOTES + subscription_created active new paid signup + subscription_updated active any field change + subscription_cancelled cancelled cancel_at_period_end=true + subscription_resumed active cancel_at_period_end=false + subscription_expired expired terminal downgrade + subscription_paused paused soft downgrade + subscription_unpaused active resume after pause + subscription_payment_success active renewal cleared + subscription_payment_failed past_due enters dunning + subscription_payment_recovered active dunning recovered + +All other events log + return early without writing. + +LS API reference: https://docs.lemonsqueezy.com/help/webhooks +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional + +from backend.subscriptions import ( + Subscription, + has_processed_event, + invalidate_subscription_cache, + mark_event_processed, + upsert_subscription, +) + + +logger = logging.getLogger(__name__) + + +# Env-driven configuration. Read at function-call time (not import +# time) so tests can monkeypatch via monkeypatch.setenv without +# reloading the module. +def _webhook_secret() -> str: + return os.getenv("AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET", "").strip() + + +def _variant_pro() -> str: + return os.getenv("AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_PRO", "").strip() + + +def _variant_business() -> str: + return os.getenv( + "AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_BUSINESS", "" + ).strip() + + +# ─── Event-to-status mapping ──────────────────────────────────────────── + + +# (event_name -> (status_override, cancel_at_period_end_override)). +# Each override is None when the event doesn't dictate that field; +# the handler falls through to the payload's value in that case. +# Events that DO pin the field explicitly (most do, for status; only +# created / cancelled / resumed do for cancel flag) carry a concrete +# value here. +# +# `subscription_updated` deliberately leaves status=None: LS emits +# this event for any field change, including ones that don't reflect +# in a fresh status value. Trusting the payload's status keeps the +# row in sync without `subscription_updated` clobbering a prior +# cancelled / paused state. +_EVENT_TO_STATUS: dict[str, tuple[Optional[str], Optional[bool]]] = { + "subscription_created": ("active", False), + "subscription_updated": (None, None), + "subscription_cancelled": ("cancelled", True), + "subscription_resumed": ("active", False), + "subscription_expired": ("expired", None), + "subscription_paused": ("paused", None), + "subscription_unpaused": ("active", None), + "subscription_payment_success": ("active", None), + "subscription_payment_failed": ("past_due", None), + "subscription_payment_recovered": ("active", None), +} + + +# Mapping from LS payload `data.attributes.status` -> our status +# column. LS sends "on_trial" for free-trial subscriptions; we treat +# them as Free until they convert (trial revenue isn't worth the +# additional state). "unpaid" is the legacy name for past_due in +# some LS versions; alias it. Unknown values fall back to "expired" +# so the user is downgraded conservatively. +_LS_STATUS_TO_OUR_STATUS: dict[str, str] = { + "active": "active", + "cancelled": "cancelled", + "expired": "expired", + "paused": "paused", + "past_due": "past_due", + "unpaid": "past_due", + "on_trial": "active", +} + + +# ─── Exceptions ───────────────────────────────────────────────────────── + + +class InvalidWebhookSignature(Exception): + """X-Signature header mismatch on the raw request body. + + Raised by `verify_signature`. The route converts this to a 401 -- + LS retries on 5xx but NOT on 4xx, so a 401 also stops the retry + loop on a misconfigured webhook secret (which is what we want; + we shouldn't quietly accumulate retries on bad signatures).""" + + +class WebhookConfigError(Exception): + """The webhook handler isn't configured for production. + + Currently fires when AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET is + empty. The route converts this to a 503 so LS doesn't retry the + way it would for a 5xx -- a 503 with a Retry-After header is the + correct signal for "this endpoint is intentionally offline right + now".""" + + +# ─── Signature verification ───────────────────────────────────────────── + + +def verify_signature(*, raw_body: bytes, signature: str) -> None: + """Verify the X-Signature header against the raw body. + + LS signs each delivery with HMAC-SHA256 over the exact raw + request body, hex-encoded. We use `hmac.compare_digest` to keep + the comparison constant-time so a timing attack can't recover + the secret. + + Raises: + WebhookConfigError -- the AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET + env var isn't set. + InvalidWebhookSignature -- the signature doesn't match. + """ + secret = _webhook_secret() + if not secret: + raise WebhookConfigError( + "AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET is not configured." + ) + if not signature: + raise InvalidWebhookSignature("X-Signature header missing.") + + expected = hmac.new( + secret.encode("utf-8"), + raw_body, + hashlib.sha256, + ).hexdigest() + + # Both sides hex-encoded; compare_digest accepts str. The check + # is case-insensitive on hex but LS sends lowercase; lowercase + # the incoming signature defensively so a future LS change to + # uppercase doesn't break verification. + if not hmac.compare_digest(expected, signature.strip().lower()): + raise InvalidWebhookSignature("Signature mismatch.") + + +# ─── Payload parsing ──────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ParsedWebhook: + """The fields the handler actually cares about. Pulled out of the + LS payload by `_parse_payload`. Keeping the dataclass small means + the rest of the handler doesn't have to deep-index into nested + dicts at every branch.""" + + event_id: str + event_name: str + user_id: str + subscription_id: str + customer_id: str + variant_id: str + status_from_payload: str + renews_at: Optional[datetime] + ends_at: Optional[datetime] + cancelled_flag: bool + + +def _parse_iso_timestamp(value: Any) -> Optional[datetime]: + """Parse an LS-shaped ISO-8601 timestamp into a tz-aware + datetime. LS sends timestamps like "2026-05-31T19:14:21.000000Z" + or "2026-05-31T19:14:21Z"; both are handled by replacing Z with + +00:00 before datetime.fromisoformat. Returns None on missing / + unparseable values.""" + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def _parse_payload(raw_body: bytes) -> Optional[ParsedWebhook]: + """Pull the fields the handler cares about out of the LS envelope. + + LS envelope shape (abbreviated): + + { + "meta": { + "event_name": "subscription_created", + "webhook_id": "uuid-of-this-delivery", + "custom_data": {"user_id": ""} + }, + "data": { + "id": "12345", # LS subscription id + "type": "subscriptions", + "attributes": { + "status": "active", + "variant_id": 67890, + "customer_id": 54321, + "cancelled": false, + "renews_at": "2026-06-14T19:14:21.000000Z", + "ends_at": null, + ... + } + } + } + + Returns None when the body isn't valid JSON or doesn't carry the + expected shape. The route still returns 200 on parse failures + (logged) so LS doesn't retry a malformed delivery into a + redelivery storm. + """ + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + logger.warning("lemonsqueezy_webhook_unparseable_body") + return None + if not isinstance(payload, dict): + return None + + meta = payload.get("meta") or {} + data = payload.get("data") or {} + attributes = data.get("attributes") if isinstance(data, dict) else {} + custom_data = meta.get("custom_data") if isinstance(meta, dict) else {} + + if not isinstance(meta, dict): + meta = {} + if not isinstance(data, dict): + data = {} + if not isinstance(attributes, dict): + attributes = {} + if not isinstance(custom_data, dict): + custom_data = {} + + event_name = str(meta.get("event_name") or "").strip() + # event_id: prefer meta.webhook_id (LS-managed uuid for the + # delivery); fall back to a synthetic key combining event_name + # + subscription_id if absent (older LS payloads). The + # idempotency log treats this as the PK so it has to be stable + # across retries of the same delivery. + webhook_id = str(meta.get("webhook_id") or "").strip() + subscription_id = str(data.get("id") or "").strip() + if webhook_id: + event_id = f"lemonsqueezy:{webhook_id}" + elif subscription_id and event_name: + event_id = f"lemonsqueezy:{event_name}:{subscription_id}" + else: + event_id = "" + + user_id = str(custom_data.get("user_id") or "").strip() + + variant_id_raw = attributes.get("variant_id") + variant_id = str(variant_id_raw) if variant_id_raw is not None else "" + + customer_id_raw = attributes.get("customer_id") + customer_id = str(customer_id_raw) if customer_id_raw is not None else "" + + return ParsedWebhook( + event_id=event_id, + event_name=event_name, + user_id=user_id, + subscription_id=subscription_id, + customer_id=customer_id, + variant_id=variant_id, + status_from_payload=str(attributes.get("status") or "").strip(), + renews_at=_parse_iso_timestamp(attributes.get("renews_at")), + ends_at=_parse_iso_timestamp(attributes.get("ends_at")), + cancelled_flag=bool(attributes.get("cancelled") or False), + ) + + +# ─── Variant -> tier mapping ──────────────────────────────────────────── + + +def _tier_for_variant(variant_id: str) -> Optional[str]: + """Resolve an LS variant_id to our tier name. + + Returns None when the variant isn't recognized -- the handler + logs + returns early so LS doesn't retry a misconfigured variant + until the env var is fixed. Both env vars must be set in + production; the local-dev path keeps them empty and the handler + short-circuits at the route level via WebhookConfigError if the + secret is missing. + """ + variant_id = str(variant_id or "").strip() + if not variant_id: + return None + if variant_id == _variant_pro(): + return "pro" + if variant_id == _variant_business(): + return "business" + return None + + +# ─── Event application ────────────────────────────────────────────────── + + +def _resolve_period_end( + event_name: str, + *, + renews_at: Optional[datetime], + ends_at: Optional[datetime], +) -> Optional[datetime]: + """Pick the right boundary for current_period_end based on event. + + LS sends both `renews_at` (the next renewal, set on active subs) + and `ends_at` (the cutoff for tier access on cancelled subs). + On cancellation, the resolver in `backend.tiers` needs ends_at + so cancelled-but-not-yet-expired subs keep tier access. On + everything else, renews_at is the right boundary. + """ + if event_name == "subscription_cancelled": + return ends_at or renews_at + if event_name == "subscription_expired": + # No future boundary; the resolver will see status="expired" + # and downgrade regardless. Surface ends_at if present so + # the row is informative. + return ends_at or renews_at + return renews_at or ends_at + + +def _apply_event(parsed: ParsedWebhook) -> bool: + """Map a parsed webhook to an upsert. Returns True on success, + False when the event was skipped (unknown event, unknown variant, + missing user_id). + + Skipped events still consume the idempotency log slot so LS + doesn't keep redelivering the same dud -- there's no value in + retrying a webhook for a variant we don't recognize. + """ + mapping = _EVENT_TO_STATUS.get(parsed.event_name) + if mapping is None: + logger.info( + "lemonsqueezy_webhook_unhandled_event event=%s", + parsed.event_name, + ) + return False + + if not parsed.user_id: + # custom_data.user_id is how we bind an LS subscription to + # our Supabase user. If the checkout was started without it + # (manual subscription created in the LS dashboard, for + # example), we have nothing to write -- log + skip. + logger.warning( + "lemonsqueezy_webhook_missing_user_id event=%s subscription_id=%s", + parsed.event_name, + parsed.subscription_id, + ) + return False + + if not parsed.subscription_id: + logger.warning( + "lemonsqueezy_webhook_missing_subscription_id event=%s", + parsed.event_name, + ) + return False + + tier = _tier_for_variant(parsed.variant_id) + if tier is None: + logger.warning( + "lemonsqueezy_webhook_unknown_variant event=%s variant_id=%s", + parsed.event_name, + parsed.variant_id, + ) + return False + + status_override, cancel_override = mapping + if status_override is not None: + status = status_override + else: + # subscription_updated falls through here. Mirror the + # payload's status field through the LS->our mapping so the + # row reflects the real state (cancelled / paused / etc). + # Unknown values are mapped to "expired" defensively. + raw_status = parsed.status_from_payload.lower() + status = _LS_STATUS_TO_OUR_STATUS.get(raw_status, "expired") + if cancel_override is not None: + cancel_at_period_end = cancel_override + else: + # Fall back to the payload's cancelled flag. LS sets this on + # updated events when the user toggles cancel-at-period-end + # mid-period without re-firing subscription_cancelled. + cancel_at_period_end = parsed.cancelled_flag + + period_end = _resolve_period_end( + parsed.event_name, + renews_at=parsed.renews_at, + ends_at=parsed.ends_at, + ) + + sub = Subscription( + user_id=parsed.user_id, + processor="lemonsqueezy", + processor_customer_id=parsed.customer_id, + processor_subscription_id=parsed.subscription_id, + tier=tier, + status=status, + current_period_end=period_end, + cancel_at_period_end=cancel_at_period_end, + variant_id=parsed.variant_id, + ) + upsert_subscription(sub) + invalidate_subscription_cache() + return True + + +# ─── Public entry point ───────────────────────────────────────────────── + + +def process_webhook(*, raw_body: bytes, signature: str) -> dict[str, Any]: + """Verify + parse + apply an LS webhook delivery. + + Returns a small status dict describing what happened. The FastAPI + route renders this as a 200 JSON response on every signature- + valid call -- even when the event was skipped (unknown event, + duplicate delivery, missing user_id). The signature check is the + only failure path that bubbles up; everything else logs + returns + a "skipped" status so LS doesn't retry. + + The dict shape: + { + "status": "applied" | "duplicate" | "skipped" | "ignored", + "event_name": , + "event_id": , + "reason": # for status="skipped"/"ignored" + } + """ + # 1. Signature verification. The route wraps WebhookConfigError + # to a 503; InvalidWebhookSignature to a 401. + verify_signature(raw_body=raw_body, signature=signature) + + # 2. Parse. Unparseable -> 200 ignored, no idempotency log slot + # consumed (we don't know event_id, so we can't dedupe anyway). + parsed = _parse_payload(raw_body) + if parsed is None: + return {"status": "ignored", "reason": "unparseable_body"} + + # 3. Idempotency check. LS retries on non-2xx (and has at-least + # -once semantics), so duplicate deliveries are expected. + if parsed.event_id and has_processed_event(parsed.event_id): + return { + "status": "duplicate", + "event_name": parsed.event_name, + "event_id": parsed.event_id, + } + + # 4. Apply. + applied = _apply_event(parsed) + + # 5. Mark processed regardless of apply outcome -- a skipped + # event (unknown variant, missing user_id) shouldn't keep + # re-delivering. The PK on subscription_webhook_log catches + # genuine duplicate deliveries before we re-enter this branch. + if parsed.event_id: + mark_event_processed(parsed.event_id, parsed.event_name) + + if applied: + return { + "status": "applied", + "event_name": parsed.event_name, + "event_id": parsed.event_id, + } + return { + "status": "skipped", + "event_name": parsed.event_name, + "event_id": parsed.event_id, + } + + +__all__ = [ + "InvalidWebhookSignature", + "ParsedWebhook", + "WebhookConfigError", + "process_webhook", + "verify_signature", +] diff --git a/backend/workspace_models.py b/backend/workspace_models.py new file mode 100644 index 0000000..2a1d686 --- /dev/null +++ b/backend/workspace_models.py @@ -0,0 +1,256 @@ +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class UploadedFilePayloadModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + filename: str = Field(min_length=1, max_length=255) + mime_type: str = Field(min_length=1, max_length=120) + content_base64: str = Field(min_length=1) + + @field_validator("filename", "mime_type", "content_base64", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + +class WorkspaceAnalyzeRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + resume_text: str = Field(min_length=1) + resume_filetype: str = Field(default="TXT", max_length=40) + resume_source: str = Field(default="workspace", max_length=120) + job_description_text: str = Field(min_length=1) + imported_job_posting: dict[str, Any] | None = None + run_assisted: bool = False + # `premium` opts into the higher-trust model routing (lands in a + # later step) AND charges against the tier's premium_applications + # counter instead of tailored_applications. Free tier has a + # premium cap of 0, so a Free user setting premium=True gets a + # 429 with a "Pro+ only" message via the global quota handler. + # Defaults to False so existing callers (and clients on the + # current frontend) keep landing on the standard tailored path + # without a wire-protocol change. + premium: bool = False + + @field_validator( + "resume_text", + "resume_filetype", + "resume_source", + "job_description_text", + mode="before", + ) + @classmethod + def _strip_required_text(cls, value): + return str(value or "").strip() + + +class WorkspaceAnalyzeJobCreatedResponseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + job_id: str + status: str + stage_title: str | None = None + stage_detail: str | None = None + progress_percent: int = 0 + result: dict[str, Any] | None = None + error_message: str | None = None + + +class WorkspaceAnalyzeJobStatusResponseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + job_id: str + status: str + stage_title: str | None = None + stage_detail: str | None = None + progress_percent: int = 0 + result: dict[str, Any] | None = None + error_message: str | None = None + + +class WorkspaceSaveRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_snapshot: dict[str, Any] + + +class SavedJobRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + job_posting: dict[str, Any] + + +class WorkspaceArtifactExportRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_snapshot: dict[str, Any] + artifact_kind: Literal["tailored_resume", "cover_letter"] + # DOCX replaced the markdown download in Phase 2 of the DOCX + # export plan; markdown lives only as the in-app preview content + # field, not as a download format. + export_format: Literal["pdf", "docx"] + resume_theme: str = Field(default="classic_ats", max_length=80) + cover_letter_theme: str = Field(default="classic_ats", max_length=80) + + @field_validator("resume_theme", "cover_letter_theme", mode="before") + @classmethod + def _strip_theme(cls, value): + return str(value or "").strip() + + +class WorkspaceArtifactPreviewRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + workspace_snapshot: dict[str, Any] + artifact_kind: Literal["tailored_resume", "cover_letter"] + resume_theme: str = Field(default="classic_ats", max_length=80) + cover_letter_theme: str = Field(default="classic_ats", max_length=80) + + @field_validator("resume_theme", "cover_letter_theme", mode="before") + @classmethod + def _strip_preview_theme(cls, value): + return str(value or "").strip() + + +class AssistantHistoryTurnModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + question: str = Field(min_length=1, max_length=1000) + answer: str = Field(min_length=1, max_length=4000) + + @field_validator("question", "answer", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + +class ResumeSummaryModel(BaseModel): + """Compact projection of a parsed CandidateProfile. + + Counts + identity only — never includes raw resume text. Sent on + every assistant turn so the LLM can answer "is my resume parsed?" + style questions without us shipping the full profile blob. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(default="", max_length=200) + location: str = Field(default="", max_length=200) + skills_count: int = Field(default=0, ge=0) + # Count of work-experience *entries* on the resume (e.g. 4 jobs + # held). NOT years of total experience — the earlier name + # `experience_count` led the LLM to answer "how many years?" + # with the entry count. + experience_entries_count: int = Field(default=0, ge=0) + has_certifications: bool = False + + +class JdSummaryModel(BaseModel): + """Compact projection of a parsed JobDescription / review. + + Counts + identity only — never includes the full JD body. + """ + + model_config = ConfigDict(extra="forbid") + + title: str = Field(default="", max_length=200) + location: str | None = Field(default=None, max_length=200) + hard_skills_count: int = Field(default=0, ge=0) + soft_skills_count: int = Field(default=0, ge=0) + must_haves_count: int = Field(default=0, ge=0) + + +class WorkspaceStateContextModel(BaseModel): + """Live workspace state, sent with every assistant request. + + Replaces the "blind" pre-analysis assistant — the LLM now sees + which step the user is on, whether they've parsed a resume / JD, + how many jobs they've saved, etc. The full + `workspace_snapshot` (analysis result) still rides separately + when an analysis has run. + """ + + model_config = ConfigDict(extra="forbid") + + current_step: Literal["resume", "jobs", "jd", "analysis"] + has_resume: bool = False + resume_summary: ResumeSummaryModel | None = None + has_jd: bool = False + jd_summary: JdSummaryModel | None = None + has_analysis: bool = False + saved_jobs_count: int = Field(default=0, ge=0) + last_search_query: str | None = Field(default=None, max_length=200) + + +class WorkspaceAssistantRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + question: str = Field(min_length=1, max_length=1000) + current_page: str = Field(default="Workspace", max_length=120) + workspace_state: WorkspaceStateContextModel | None = None + workspace_snapshot: dict[str, Any] | None = None + history: list[AssistantHistoryTurnModel] = Field(default_factory=list) + + @field_validator("question", "current_page", mode="before") + @classmethod + def _strip_text(cls, value): + return str(value or "").strip() + + +class ResumeBuilderMessageRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(min_length=1, max_length=120) + message: str = Field(min_length=1, max_length=8000) + input_mode: Literal["text", "voice"] = "text" + + @field_validator("session_id", "message", mode="before") + @classmethod + def _strip_builder_text(cls, value): + return str(value or "").strip() + + +class ResumeBuilderSessionRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(min_length=1, max_length=120) + + @field_validator("session_id", mode="before") + @classmethod + def _strip_session_id(cls, value): + return str(value or "").strip() + + +class ResumeBuilderUpdateRequestModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(min_length=1, max_length=120) + draft_profile: dict[str, Any] + + @field_validator("session_id", mode="before") + @classmethod + def _strip_update_session_id(cls, value): + return str(value or "").strip() + + +class ResumeBuilderExportRequestModel(BaseModel): + """Phase 5: download the generated base resume as PDF or DOCX. + + The resume builder is a separate intake surface (no JD context), + so the export bypasses the workspace_snapshot pipeline and + synthesizes a TailoredResumeArtifact straight from the session's + draft profile.""" + + model_config = ConfigDict(extra="forbid") + + session_id: str = Field(min_length=1, max_length=120) + export_format: Literal["pdf", "docx"] + theme: Literal["classic_ats", "professional_neutral"] = "classic_ats" + + @field_validator("session_id", mode="before") + @classmethod + def _strip_export_session_id(cls, value): + return str(value or "").strip() diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/adr/ADR-001-lightweight-document-parsing.md b/docs/adr/ADR-001-lightweight-document-parsing.md new file mode 100644 index 0000000..bd633e9 --- /dev/null +++ b/docs/adr/ADR-001-lightweight-document-parsing.md @@ -0,0 +1,20 @@ +# ADR-001: Lightweight document parsing for MVP + +## Status + +Accepted + +## Context + +The project needed a fast way to parse resumes and job descriptions without introducing a large orchestration stack early. + +## Decision + +Use `pypdf` and `python-docx` for PDF and DOCX ingestion, plus direct text decoding for TXT files. + +## Consequences + +- Parsing stays simple and easy to debug. +- The app remains lightweight enough for Streamlit-first development. +- Complex layouts and scanned PDFs still need future OCR or richer document tooling. + diff --git a/docs/adr/ADR-002-demo-assets-for-reproducible-product-flows.md b/docs/adr/ADR-002-demo-assets-for-reproducible-product-flows.md new file mode 100644 index 0000000..facbef2 --- /dev/null +++ b/docs/adr/ADR-002-demo-assets-for-reproducible-product-flows.md @@ -0,0 +1,20 @@ +# ADR-002: Demo assets for reproducible product flows + +## Status + +Accepted + +## Context + +The app needs to be demoable even when a user does not have a resume or job description ready. + +## Decision + +Keep sample resumes and job descriptions in `static/` and expose them directly in the UI. + +## Consequences + +- The parsing flows are easier to validate during development and demos. +- The repo can demonstrate core functionality without external setup. +- Static demo assets must remain curated and non-sensitive. + diff --git a/docs/adr/ADR-003-streamlit-session-state-for-navigation-and-persistence.md b/docs/adr/ADR-003-streamlit-session-state-for-navigation-and-persistence.md new file mode 100644 index 0000000..7d081f0 --- /dev/null +++ b/docs/adr/ADR-003-streamlit-session-state-for-navigation-and-persistence.md @@ -0,0 +1,20 @@ +# ADR-003: Streamlit session state for navigation and persistence + +## Status + +Superseded by ADR-012 + +## Context + +The product is a Streamlit MVP with multiple user flows that share parsed inputs. + +## Decision + +Use `st.session_state` to preserve the active menu and parsed payloads across reruns. + +## Consequences + +- Navigation stays simple without introducing a backend session store. +- Parsed inputs survive page switches in the same browser session. +- This approach is not sufficient for multi-user persistence or resumable workflows. + diff --git a/docs/adr/ADR-004-linkedin-data-export-ingestion-instead-of-direct-api-access.md b/docs/adr/ADR-004-linkedin-data-export-ingestion-instead-of-direct-api-access.md new file mode 100644 index 0000000..8fec3a2 --- /dev/null +++ b/docs/adr/ADR-004-linkedin-data-export-ingestion-instead-of-direct-api-access.md @@ -0,0 +1,21 @@ +# ADR-004: LinkedIn data export ingestion instead of direct API access + +## Status + +Superseded by ADR-007 + +## Context + +Direct LinkedIn profile access is constrained by product restrictions and Terms of Service concerns. + +## Decision + +Accept user-uploaded LinkedIn data export ZIP files instead of scraping or attempting direct profile import. + +## Consequences + +- The app stays inside a safer product boundary. +- Users retain control over what profile data they share with the app. +- The workflow adds friction because users must export their data before upload. +- This decision is no longer active product scope because the export-based flow added too much intake friction for the MVP. + diff --git a/docs/adr/ADR-005-streamlit-first-backend-ready-delivery.md b/docs/adr/ADR-005-streamlit-first-backend-ready-delivery.md new file mode 100644 index 0000000..e6732e9 --- /dev/null +++ b/docs/adr/ADR-005-streamlit-first-backend-ready-delivery.md @@ -0,0 +1,59 @@ +# ADR-005: Streamlit-first, backend-ready delivery strategy + +## Status + +Superseded by ADR-012 + +## Context + +The AI Job Application Agent needs to ship an initial public version quickly, but it also has a likely long-term path toward a production architecture with a dedicated backend and a richer frontend. + +The team discussed whether to begin immediately with: + +- FastAPI +- Redis +- Postgres +- React or Next.js +- Dockerized deployment + +That stack is appropriate later, but would add significant platform complexity before the core workflow is validated. + +## Decision + +Use Streamlit for the first deployable product version, while structuring the codebase so business logic can later be exposed through FastAPI and consumed by a separate frontend. + +Specific implications: + +- `app.py` remains a UI layer +- business logic should move into `src/` services and agent modules +- the app keeps sidebar navigation because the product has multiple workflows +- Redis is deferred until background jobs or shared cache become necessary +- Docker is not required to justify the initial product, but remains the right runtime boundary once backend extraction becomes real +- Next.js is the preferred frontend target when the app outgrows Streamlit + +## Consequences + +### Positive + +- faster delivery of the first public version +- simpler development and deployment in the short term +- easier product validation before infrastructure expansion +- cleaner future migration path because logic is separated from the UI + +### Negative + +- some production concerns remain deferred +- Streamlit continues to limit frontend flexibility in the short term +- the backend boundary remains implicit until FastAPI is introduced + +### Follow-up + +The codebase should now evolve toward: + +- typed schemas +- service modules +- supervised agent orchestration +- deterministic report assembly +- backend-ready persistence and export boundaries + +That work will make the later FastAPI and Next.js migration much easier. diff --git a/docs/adr/ADR-006-playwright-first-pdf-export.md b/docs/adr/ADR-006-playwright-first-pdf-export.md new file mode 100644 index 0000000..4ed001b --- /dev/null +++ b/docs/adr/ADR-006-playwright-first-pdf-export.md @@ -0,0 +1,69 @@ +# ADR-006: Playwright-First PDF Export with ReportLab Fallback + +## Status + +Superseded on March 16, 2026 + +## Context + +This ADR captured the earlier browser-rendering decision before the Windows runtime work on March 16, 2026 moved the product to a WeasyPrint-first exporter. + +The application strategy report can already be assembled deterministically as Markdown, but recruiter-facing output also needs a polished PDF form. + +Plain-text export is not sufficient for the product direction because: + +- users need a presentation-ready artifact they can share directly +- Markdown is useful for editing, but not as the final polished package +- the app already has a product-style visual system that should carry into exported output + +The GitHub agent solved a similar problem by using browser-based PDF rendering rather than relying on a low-level PDF layout engine alone. + +## Original Decision + +Use Playwright/Chromium as the primary PDF backend for application-package export, with ReportLab retained as a fallback. + +The export flow is: + +1. build the deterministic application strategy report as Markdown +2. render that Markdown into styled HTML/CSS +3. print the HTML through Chromium when available +4. fall back to ReportLab if the browser backend is unavailable or fails + +Markdown remains the editable export format. PDF is the polished export format. + +## Alternatives Considered + +### 1. Markdown only + +Rejected because it does not produce a polished recruiter-facing artifact. + +### 2. ReportLab only + +Rejected as the primary path because it is harder to evolve visually and tends to produce less natural document layout than browser rendering. + +### 3. PDF generation directly from Streamlit UI markup + +Rejected because it would couple export behavior too tightly to the UI runtime and make deterministic package rendering harder to control. + +## Consequences + +- PDF output can preserve a stronger visual hierarchy through HTML/CSS +- Markdown remains available for user edits before export +- the repo now depends on Playwright plus a Chromium install for the best PDF output +- fallback PDF generation still exists if the Playwright backend is unavailable +- deployment environments will need to account for the browser dependency explicitly + +## Superseded By + +Initially superseded informally by a WeasyPrint-based pipeline once the local Windows runtime made Playwright subprocess startup unreliable. The current export pipeline is documented in [ADR-015: DOCX-first artifact export with theme palette](ADR-015-docx-first-artifact-export-with-theme-palette.md), which: + +- keeps WeasyPrint as the primary HTML-to-PDF renderer +- adds DOCX as a first-class artifact-export format alongside PDF +- removes Markdown export entirely +- ships a shared theme palette (`classic_ats`, `professional_neutral`) that resolves consistently across both PDF and DOCX + +Reason for the original Playwright → WeasyPrint move: + +- the local Windows runtime was unreliable for Playwright subprocess startup +- WeasyPrint better matches the product goal of HTML/CSS-driven document templates without a browser dependency +- the active exporter no longer depends on Playwright or Chromium diff --git a/docs/adr/ADR-007-remove-linkedin-import-from-active-product-scope.md b/docs/adr/ADR-007-remove-linkedin-import-from-active-product-scope.md new file mode 100644 index 0000000..711ed0c --- /dev/null +++ b/docs/adr/ADR-007-remove-linkedin-import-from-active-product-scope.md @@ -0,0 +1,37 @@ +# ADR-007: Remove LinkedIn import from active product scope + +## Status + +Accepted + +## Context + +The project originally supported LinkedIn data-export ZIP ingestion as a safer alternative to direct profile scraping or API access. + +That workflow was technically workable, but it created product friction at exactly the wrong point in the user journey: + +- users had to leave the app and request an export from LinkedIn +- delivery depended on LinkedIn's archive flow and timing +- the imported data quality varied across exports +- the feature expanded schema, UI, parser, and testing surface area without improving the core resume-to-application outcome enough to justify the complexity + +The current MVP is stronger when it focuses on the lowest-friction path: resume input plus target job description. + +## Decision + +Remove LinkedIn import from the active product and codebase. + +Keep the product centered on: + +- resume parsing +- job-description structuring +- deterministic fit and tailoring helpers +- supervised agent refinement +- exportable application-package generation + +## Consequences + +- The user journey is simpler and faster. +- Candidate-profile handling becomes easier to reason about because there is one active intake source. +- The codebase loses parser, schema, UI, and test complexity that was specific to LinkedIn ingestion. +- Historical ADR-004 remains useful as a record of an earlier product direction, but it no longer governs the active application scope. \ No newline at end of file diff --git a/docs/adr/ADR-008-two-mode-grounded-assistant-panel.md b/docs/adr/ADR-008-two-mode-grounded-assistant-panel.md new file mode 100644 index 0000000..af5bf1d --- /dev/null +++ b/docs/adr/ADR-008-two-mode-grounded-assistant-panel.md @@ -0,0 +1,60 @@ +# ADR-008: Two-Mode Grounded Assistant Panel + +## Status + +Superseded by ADR-011 + +## Context + +The product now generates richer outputs than the original deterministic fit snapshot alone: + +- a tailored resume artifact +- an application report / strategy package +- comparison and validation views +- an explicit supervised workflow with revision-aware review + +That makes the product more useful, but it also increases navigation and interpretation overhead. + +Users need help in two distinct categories: + +- how to use the product itself +- how to understand the current resume, JD, and generated outputs + +Treating both needs as one undifferentiated assistant would weaken grounding and increase the risk of mixing product guidance with application-specific claims. + +## Decision + +Add one shared assistant surface with two explicit modes: + +- `Using the App` +- `About My Resume` + +Implementation details: + +1. keep one assistant service in code rather than creating multiple extra orchestrator agents +2. route product-help questions through a bounded product-help mode +3. route application-specific questions through a grounded workflow-Q&A mode +4. keep both modes constrained to structured JSON responses and deterministic fallback behavior +5. expose the assistant in the active UI pages where users need help, instead of creating a separate chat-only destination + +## Alternatives Considered + +### 1. No assistant + +Rejected because the product has passed the point where static UI copy alone is sufficient to explain the flow and outputs. + +### 2. One freeform assistant with no explicit modes + +Rejected because it increases grounding risk and blurs the difference between product navigation and candidate-specific interpretation. + +### 3. Separate orchestrated specialist chat agents + +Rejected for now because that would add architectural and cost complexity without strong evidence that the current two-mode panel is insufficient. + +## Consequences + +- the product gets a lightweight help surface without creating a second orchestration pipeline +- product-help questions can stay on a cheaper model tier +- application-Q&A can use a higher-trust model tier when needed +- assistant behavior remains grounded in current workflow state rather than acting as an open-ended chatbot +- future assistant expansion should start from the current two-mode panel before adding more agent roles \ No newline at end of file diff --git a/docs/adr/ADR-009-google-sign-in-via-supabase-for-persistent-identity.md b/docs/adr/ADR-009-google-sign-in-via-supabase-for-persistent-identity.md new file mode 100644 index 0000000..3591155 --- /dev/null +++ b/docs/adr/ADR-009-google-sign-in-via-supabase-for-persistent-identity.md @@ -0,0 +1,56 @@ +# ADR-009: Google Sign-In via Supabase for Persistent Identity + +## Status + +Accepted + +## Context + +The current product can already benefit from persistent identity even before billing exists. + +Session-based Streamlit state is sufficient for local prototype flow, but it is not a durable foundation for: + +- per-user usage tracking +- plan-based or daily assisted limits +- cross-device continuity +- saved workspace continuity +- future subscriptions or billing entitlements + +The product direction now includes assisted workflow usage tracking and eventually monetization. That makes browser-only session identity too weak to govern product access and usage. + +## Decision + +Adopt Google sign-in as the primary user login method, using Supabase Auth as the initial identity and persistence layer. + +The architecture decision is: + +1. keep Streamlit as the current product UI +2. move from browser-session-only identity toward persistent authenticated users +3. use Supabase Auth with Google as the first implementation path +4. persist usage and account metadata outside local Streamlit session state +5. treat quotas, plans, and future billing as account-level concerns rather than UI-only state + +Google sign-in is the chosen user-facing authentication method because it reduces friction. Supabase is the chosen implementation foundation because it combines auth and persistent storage with lower delivery overhead than a custom auth backend. + +## Alternatives Considered + +### 1. Keep anonymous session-only usage + +Rejected because it does not support durable quotas, meaningful tracking, or future paid plans. + +### 2. Build a custom backend-auth stack first + +Rejected for the current phase because it is slower to deliver and unnecessary before stronger evidence of custom auth requirements exists. + +### 3. Use Firebase Auth as the first path + +Considered viable, but not chosen initially because Supabase better aligns with the need for relational product data such as usage records, saved workspace state, and entitlement state. + +## Consequences + +- auth becomes a first-class product concern rather than a later add-on +- usage limits can evolve from session-based estimates to real per-user enforcement +- saved workspace state can attach to a stable account identity +- the app will need secure handling of auth sessions and user-owned data +- backend persistence becomes part of the product foundation even while Streamlit remains the UI shell +- Google sign-in should be implemented before daily quotas or billing so those later systems rest on real identity diff --git a/docs/adr/ADR-010-single-pass-review-corrections-and-task-tuned-model-budgets.md b/docs/adr/ADR-010-single-pass-review-corrections-and-task-tuned-model-budgets.md new file mode 100644 index 0000000..f00edc1 --- /dev/null +++ b/docs/adr/ADR-010-single-pass-review-corrections-and-task-tuned-model-budgets.md @@ -0,0 +1,79 @@ +# ADR-010: Single-Pass Review Corrections and Task-Tuned Model Budgets + +## Status + +Accepted + +## Context + +The earlier supervised workflow had grown into a more expensive sequence than the product needed: + +- `ProfileAgent` and `JobAgent` were generating summaries that mostly restated deterministic candidate and JD structure already available elsewhere in the system +- the orchestrator ran a bounded revision loop that resent tailoring and review through another full pass when review rejected the first draft +- Review was functioning as both a gate and a revision trigger, but the product goal had shifted toward direct grounded correction rather than repeated full-pipeline iteration +- real runtime logs showed that the second tailoring + review pass was one of the largest contributors to total latency +- the final high-trust stages were worth more model budget than the early summarization stages, but the routing defaults had not yet been tightened around that reality + +At the same time, the product still needed: + +- deterministic fit analysis as the grounding backbone +- a strong Tailoring step because that stage carries the most content-heavy rewrite work +- a strong Review step that can reject or repair unsupported wording +- a final Resume Generation step that turns the reviewed output into the export-ready artifact + +## Decision + +Adopt a single-pass supervised workflow with direct review corrections and task-tuned reasoning / output budgets. + +The accepted workflow is: + +1. `fit` +2. `tailoring` +3. `review` +4. `resume_generation` +5. `cover_letter` + +Implementation details: + +1. remove live `ProfileAgent` and `JobAgent` execution from the active orchestrator path +2. keep deterministic `CandidateProfile`, `JobDescription`, `FitAnalysis`, and `TailoredResumeDraft` as the source-of-truth inputs +3. make Review return direct corrected tailoring output when repairs are straightforward +4. stop rerunning the entire tailoring / review chain after review feedback +5. define review approval in terms of the final corrected state, not only the cleanliness of the incoming draft +6. route earlier cheaper stages to cheaper reasoning levels than the final grounding stages +7. tune output-token caps by observed usage instead of using one oversized default for every task + +The current routing defaults following this decision are: + +- `fit`: `gpt-5-mini-2025-08-07` with `low` reasoning and a 1600-token output cap +- `tailoring`: `gpt-5-mini-2025-08-07` with `medium` reasoning and a 3200-token output cap +- `review`: `gpt-5.4` with `medium` reasoning and a 4000-token output cap +- `resume_generation`: `gpt-5.4` with `medium` reasoning and a 3000-token output cap +- `cover_letter`: `gpt-5.4` with `medium` reasoning and a 3000-token output cap + +## Alternatives Considered + +### 1. Keep the full Profile + Job + Fit + Tailoring + Review + Resume Generation stack + +Rejected because Profile and Job were not adding enough unique value relative to the deterministic data they were summarizing, while still costing additional sequential model latency. + +### 2. Keep the revision loop but reduce model size only + +Rejected because the largest avoidable cost was architectural: repeated full-pipeline passes. Model tuning alone would not remove that structural latency. + +### 3. Remove Review entirely and trust Tailoring output directly + +Rejected because Review is still the main grounding defense against unsupported claims, inferred tenure, and overstated tooling experience. + +### 4. Lower every stage to the same cheapest reasoning tier + +Rejected because the stages do not have the same risk profile. Review and final resume generation justify more careful reasoning than early fit and tailoring guidance. + +## Consequences + +- the workflow becomes materially faster because it removes redundant live stages and the second-pass loop +- deterministic inputs remain the grounding backbone even though the live agent count is smaller +- Review becomes a direct correcting editor rather than only a rejection gate +- the meaning of `approved` must reflect the final corrected output state, which required updates to UI and report wording +- model routing becomes easier to reason about because costlier reasoning is reserved for the stages that materially affect grounding and final export quality +- PDF output quality remains a separate follow-up concern; the workflow and routing changes improve runtime and correctness, but they do not solve visual export polish by themselves diff --git a/docs/adr/ADR-011-unified-grounded-assistant-surface.md b/docs/adr/ADR-011-unified-grounded-assistant-surface.md new file mode 100644 index 0000000..5d054b1 --- /dev/null +++ b/docs/adr/ADR-011-unified-grounded-assistant-surface.md @@ -0,0 +1,63 @@ +# ADR-011: Unified Grounded Assistant Surface + +## Status + +Accepted + +## Context + +The product originally exposed one assistant surface with two explicit modes: + +- `Using the App` +- `About My Resume` + +That separation helped at first, but the product has since changed in ways that made the split less useful and more brittle: + +- the JD flow now exposes multiple first-class artifacts, including the tailored resume, cover letter, and application strategy report +- saved workspace restore is now a first-class authenticated behavior and part of the normal user flow +- assistant prompts and static product knowledge had started to drift from the actual runtime experience because the same feature had to be described in two separate assistant paths +- the UI forced users to decide which assistant mode they needed before asking a question, even when many questions naturally span both product usage and current-output interpretation + +In practice, users ask blended questions such as: + +- where a generated artifact came from +- what was saved and what reload restores +- whether a current output is safe or grounded +- how to use a current feature in the context of the active workflow state + +Those are not cleanly separable into product-help-only versus application-QA-only categories. + +## Decision + +Adopt one unified in-app assistant surface that can answer both product and workflow questions in the same conversation. + +Implementation details: + +1. keep one assistant UI with one shared chat history instead of a mode toggle +2. build one assistant prompt contract that receives both product context and workflow context together +3. keep runtime session data authoritative for current page state, quotas, saved-workspace behavior, and active artifacts +4. allow broader coaching when appropriate, but require workflow-grounded answers for candidate-specific or artifact-specific claims +5. preserve deterministic fallback behavior so the assistant still works when model responses fail or assisted limits are reached +6. keep the assistant outside the supervised artifact-generation pipeline because it is still conversational support, not a workflow output stage + +## Alternatives Considered + +### 1. Keep the two-mode assistant panel + +Rejected because the split had become a UX burden and a maintenance burden. It duplicated prompt logic, knowledge maintenance, and session handling while no longer matching how users ask questions. + +### 2. Route all assistant questions to a completely freeform chatbot + +Rejected because the product still needs strong grounding to the current runtime state and active workflow artifacts. A generic chatbot would increase drift and reduce trust. + +### 3. Add more specialist assistant modes + +Rejected because the current problem was too much mode complexity, not too little. More modes would make the UI harder to use and increase prompt maintenance cost further. + +## Consequences + +- the assistant becomes easier to use because users no longer have to classify their question before asking it +- product explanations and artifact explanations now share one source of conversational truth +- the assistant can answer cross-cutting questions about resume, cover letter, report, saved workspace restore, and quotas without switching modes +- prompt and knowledge maintenance become simpler because current-flow updates only need to be applied once +- the assistant still needs careful grounding discipline so that broader coaching does not drift into unsupported claims about the user's materials diff --git a/docs/adr/ADR-012-nextjs-workspace-and-fastapi-runtime-baseline.md b/docs/adr/ADR-012-nextjs-workspace-and-fastapi-runtime-baseline.md new file mode 100644 index 0000000..1c613e9 --- /dev/null +++ b/docs/adr/ADR-012-nextjs-workspace-and-fastapi-runtime-baseline.md @@ -0,0 +1,60 @@ +# ADR-012: Next.js Workspace And FastAPI Runtime Baseline + +- Status: Accepted +- Date: 2026-04-24 + +## Context + +The product outgrew the old Streamlit runtime. + +We needed: + +- a cleaner hosted frontend for the user workspace +- a separate backend runtime on the VPS +- explicit API boundaries for auth, job search, JD review, workflow runs, exports, and persistence +- a UI that matches the real product flow instead of the earlier prototype shell + +The active product also narrowed around the clearest user value: + +- tailored resume +- cover letter +- grounded assistant help + +The earlier visible strategy/report-heavy surface and multi-theme resume presentation were adding complexity without enough user value. + +## Decision + +We standardize the live product on: + +- `Next.js` for the user-facing workspace in `frontend/` +- `FastAPI` for the backend API in `backend/` +- shared Python workflow logic in `src/` +- `Supabase` for Google auth, quotas, saved jobs, and the latest saved workspace snapshot +- one standard ATS-friendly resume output format +- a visible workspace centered on resume and cover letter + +The internal report builder may remain available in Python for backend or support use, but it is no longer a first-class visible workspace output. + +The old Streamlit shell is removed from the active runtime path. + +## Consequences + +### Positive + +- The frontend is now easier to host, iterate on, and polish. +- The backend boundary is explicit and easier to harden. +- The product story is clearer for users. +- Auth, persistence, search, and workflow execution now live behind stable API contracts. +- Resume export is simpler because there is one supported format instead of a theme switch. + +### Negative + +- The migration removes the old Streamlit simplicity for local single-process iteration. +- Long agentic runs still need further work to become proper background jobs. +- Historical ADRs and docs need active maintenance so they do not drift toward the removed Streamlit architecture again. + +## Follow-Up + +- Keep the README, architecture doc, and transition doc aligned with the live Next.js + FastAPI product. +- Move long workflow runs to background execution when operationally justified. +- Continue simplifying the workspace language so it stays user-facing rather than implementation-facing. diff --git a/docs/adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md b/docs/adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md new file mode 100644 index 0000000..7999666 --- /dev/null +++ b/docs/adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md @@ -0,0 +1,70 @@ +# ADR-013: Cached Jobs Cache Layer With Scheduled Refresh + +- Status: Accepted +- Date: 2026-05-08 + +## Context + +The earlier `/jobs/search` endpoint fanned out live to every configured Greenhouse + Lever board on every user query. Once the source pool grew past ~30 boards, two problems showed up immediately: + +- end-to-end search latency drifted to ~25 s in the live path because every board fetch hit the upstream API in series under our concurrency cap, and even the parallel branches still paid the slowest-board tax +- the cost model for adding new ATS providers got worse linearly — every new provider made every search slower, even for users searching topics that provider didn't have +- transient board failures leaked into user-facing results: one DNS hiccup at one ATS could drop a whole result set or surface "0 matches" for a query the user had run successfully five minutes earlier + +We also needed a way to keep saved-jobs alive even after the upstream board stopped listing the role, so users don't lose their bookmark history when a posting closes. + +## Decision + +Move the live fan-out behind a Supabase-cached `cached_jobs` table refreshed on a schedule by the backend itself. + +The shape: + +- A `cached_jobs` table holds one row per (source, job_id), keyed on a composite unique constraint, with the full posting metadata, `last_seen_at`, and a nullable `removed_at` tombstone field. +- A backend worker `refresh_cached_jobs()` iterates the configured ATS adapters (Greenhouse, Lever, Ashby, Workday), bulk-upserts every posting per source into `cached_jobs`, and updates `last_seen_at` to the refresh start time. +- After the upserts, the worker runs a smart cleanup that splits "rows whose `last_seen_at < refresh_start`" into two buckets: + - **Tombstone** if any user has bookmarked this (source, job_id) in `saved_jobs` — set `removed_at = now()` so the saved-jobs UI can render an "Expired" badge instead of losing the bookmark. + - **Hard delete** if nobody has bookmarked it. +- Cleanup eligibility is per-source and gated on `boards_succeeded > 0`. A provider where every board failed (transient outage) is excluded from cleanup so a single bad refresh doesn't vaporize the whole cache. +- The refresh runs on a `pg_cron` job that POSTs to `/admin/refresh-cache` via `pg_net.http_post` every ~30 min. Endpoint is protected by a constant-time bearer compare against `REFRESH_CACHE_SECRET`. +- `/jobs/search` reads from the cache by default through `JobSearchService.search_cached(...)`. A `?live=true` query param keeps a live fan-out escape hatch for diagnostics and for the rare case where a user needs strictly-real-time coverage. + +The `CachedJobsStore` uses the Supabase service-role key (RLS bypass) because the table is global, not user-scoped. RLS is still enabled on the table with no policies, as defence-in-depth. + +## Alternatives Considered + +### 1. Per-user cache keyed on the search query +Rejected. The user pool isn't large enough to amortize a per-query cache, and the natural query distribution (long-tail technical role variations) means most queries never repeat exactly. A global posting index avoids the cardinality problem. + +### 2. In-memory backend cache only +Rejected. The VPS runs a single uvicorn worker today, but a container restart would lose every cached posting and force the next user to pay the cold fan-out cost. Persisting to Supabase makes restarts cheap and gives the admin endpoint a stable surface for debugging. + +### 3. Refresh-on-write inside `/jobs/search` itself +Rejected. Coupling the refresh schedule to user traffic means the first user of a quiet hour pays for the refresh, and a quiet day means the cache silently goes stale. A separate scheduler decouples freshness from request load. + +### 4. Scheduled worker on the backend host (cron + curl) instead of `pg_cron + pg_net` +Considered but rejected. Running the cron inside Postgres means the schedule survives a backend redeploy, doesn't need separate orchestration, and is observable through the Supabase dashboard. The `pg_net` extension is already available on Supabase. + +## Consequences + +### Positive + +- Search latency dropped from ~25 s (live fan-out) to ~360 ms warm / ~5.5 s cold against the cache RPC. +- Adding a new ATS provider has constant cost: one adapter that yields `(board_token, status, payload)` triples, one entry in `_adapters_with_fetch_all()`. The user search path stays unchanged. +- Saved-jobs bookmarks survive upstream listing closures with an explicit "Expired" badge. +- Per-source isolation means a Workday outage doesn't poison Greenhouse results. +- Refresh failures are visible in the structured report returned by `/admin/refresh-cache`, so monitoring can alert on `boards_failed` counts per provider. + +### Negative + +- Cache freshness lags real-time by up to one refresh interval. For high-velocity boards, this can mean a job listed in the last 30 min isn't yet searchable. The `?live=true` escape hatch covers the diagnostic case; the trade-off is acceptable for the user-facing path. +- The cache is now another piece of operational state. A bad migration or accidentally-corrupt upsert could land bad data on every user. Mitigated by: + - per-board success gating on cleanup + - the bearer-protected admin endpoint + - structured per-provider error reporting +- The system depends on `pg_net` and `pg_cron` extensions being enabled on the Supabase project. Documented in `docs/sql/job_cache_cron_setup.sql`. + +## Follow-Up + +- See [ADR-014](ADR-014-postgres-rpc-for-ranked-search.md) for the search RPC that reads from this cache. +- Track per-provider freshness lag (`scraped_at`/`last_seen_at` distribution) once we have user volume to justify it. +- Consider per-tenant rate-limit budgets on Workday if cache volume grows past the current ~12k active rows. diff --git a/docs/adr/ADR-014-postgres-rpc-for-ranked-search.md b/docs/adr/ADR-014-postgres-rpc-for-ranked-search.md new file mode 100644 index 0000000..626bf02 --- /dev/null +++ b/docs/adr/ADR-014-postgres-rpc-for-ranked-search.md @@ -0,0 +1,95 @@ +# ADR-014: Postgres RPC For Ranked Job Search + +- Status: Accepted +- Date: 2026-05-08 + +## Context + +[ADR-013](ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) introduced the `cached_jobs` table. The user-facing search needs to: + +- run full-text search on title + company + description with typed terms +- rank by `ts_rank` when there's a query, fall back to recency when there isn't +- filter by source, location, work mode, employment type, "posted within N days", and explicit remote-only +- return a small page (≤ 50 rows) ordered by the chosen sort key + +The first attempt did this with chained PostgREST builders: + +```python +client.table("cached_jobs") + .select(...) + .text_search("description_tsv", query) + .ilike("location", f"%{location}%") + .order("posted_at", desc=True) + .limit(20) + .execute() +``` + +This failed at runtime. PostgREST's `text_search()` returns a *terminating* query builder that only exposes `.execute()` — it does not chain into `.order()`, `.limit()`, or any further filter. We could either: + +- Run the FTS as a separate query and JOIN/filter in Python, paying the round-trip cost twice, **or** +- Move the whole ranked-and-filtered query into a Postgres function and call it from PostgREST as an RPC. + +We also want the ranking expression (`ts_rank(description_tsv, websearch_to_tsquery(query))`) inside the `ORDER BY`, which PostgREST's `.order()` can't reference because it expects a column name. + +## Decision + +The ranked search lives in a Supabase-defined Postgres function (`search_cached_jobs_ranked`) called via `client.rpc("search_cached_jobs_ranked", args).execute()`. + +The function: + +- Accepts `p_query`, `p_location`, `p_sources`, `p_remote_only`, `p_posted_within_days`, `p_limit`, `p_work_modes`, `p_employment_types`, `p_sort_by` as named parameters. +- Builds a single SELECT against `cached_jobs WHERE removed_at IS NULL` with all the filters applied. +- Branches its `ORDER BY` on `p_sort_by`: + - `relevance` → `ts_rank(description_tsv, websearch_to_tsquery(p_query)) DESC` when query is non-empty, else `posted_at DESC NULLS LAST` + - `newest` → `posted_at DESC NULLS LAST` + - `oldest` → `posted_at ASC NULLS LAST` + - `company_az` → `LOWER(company) ASC` + - any other value coerces to `relevance` +- Marked `SECURITY DEFINER` and `GRANT`ed to `service_role` so the backend can call it with the same key it already uses for the cache writes. + +The Python side (`CachedJobsStore.search`) builds the kwarg dict and forwards it to the RPC. Filter values (sources, work modes, employment types, sort) are whitelisted in Python before they reach the RPC so a malformed UI param can't generate a query that returns zero rows just because of casing, and so the RPC always sees a known sort key. + +The schema gained two `GENERATED ALWAYS AS … STORED` columns to support the new filters efficiently: + +- `work_mode` — derived from `remote`, `metadata->>'workplace_type'`, and `location` keywords; one of `remote | hybrid | onsite | ''` +- `employment_type_norm` — derived from `employment_type` + `title` with Postgres word-boundary regex (`~* '\mintern(s|ship|ships)?\M'`) so "Internal Systems" and "International" don't false-match as internships; one of `fulltime | parttime | contract | internship | temporary | ''` + +Both columns get a partial index filtering on `removed_at IS NULL AND col <> ''` so the active-row scan stays cheap at ~10k+ rows. + +## Alternatives Considered + +### 1. Two separate PostgREST round trips: FTS, then re-filter + sort in Python +Rejected. Doubles the network cost for every search, doesn't paginate cleanly (you'd have to over-fetch from FTS and trim), and Python-side `ts_rank` isn't possible. + +### 2. Drop FTS entirely and use ILIKE + manual ranking +Rejected. Loses the synonym + stemming + phrase support that `websearch_to_tsquery` gives us for free, and ILIKE against a 12k-row table with no trigram index would be slow on common technical terms. + +### 3. Keep filter logic in Python, push only the FTS into a function +Considered. Cleaner separation of concerns, but every search would still pay two round trips and the Python side would have to re-query `cached_jobs` to combine results. The single-RPC approach is simpler and doesn't lose anything important. + +### 4. Use a Supabase Edge Function instead of a Postgres function +Rejected for now. Edge Functions add a TypeScript runtime + a separate deploy surface for the same logic that fits cleanly in SQL. If we ever need to do something the function language can't express (e.g., calling out to an embeddings API mid-query), we revisit. + +## Consequences + +### Positive + +- Single round trip per user search regardless of how many filters are stacked. +- Ranking expression lives where the data does, so the planner can optimize FTS + sort + filter together. +- The Python side stays small: build a kwargs dict, call rpc, parse rows. The schema contract is the function signature itself, so a contract drift between Python and the migration shows up immediately as a Postgres error in tests. +- Schema-driven filter values (`work_mode`, `employment_type_norm`) keep the dropdown UI honest — if a value isn't in the GENERATED column, it isn't in the dropdown. +- Adding a new sort or filter is a v2 migration on the function plus a Python kwarg change; the API contract stays stable. + +### Negative + +- Application logic now lives in two places: the Python store (whitelisting + kwarg shape) and the SQL function (ORDER BY branches + filter SQL). Mitigated by: + - Python tests that pin the RPC arg shape so contract drift surfaces in CI rather than at runtime. + - The function being short and well-commented; new branches are obvious. +- Supabase function changes need a migration deploy, which is heavier than a Python edit. The trade-off is acceptable because the search shape is stable. +- The `GENERATED STORED` columns make `cached_jobs` rows slightly bigger and inserts very slightly slower. At ~12k active rows this is unmeasurable. + +## Follow-Up + +- If the cache grows past ~100k rows, add a `tsvector` index on `description_tsv` and consider a `pg_trgm` index for fuzzy company-name matching. +- If we add more sort options (salary band, posted-week relevance), add them as additional `CASE` branches rather than splitting into multiple RPCs. +- See [ADR-013](ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) for the cache layer this RPC reads from. diff --git a/docs/adr/ADR-015-docx-first-artifact-export-with-theme-palette.md b/docs/adr/ADR-015-docx-first-artifact-export-with-theme-palette.md new file mode 100644 index 0000000..d4d8f4f --- /dev/null +++ b/docs/adr/ADR-015-docx-first-artifact-export-with-theme-palette.md @@ -0,0 +1,69 @@ +# ADR-015: DOCX-First Artifact Export With Theme Palette + +- Status: Accepted +- Date: 2026-05-07 + +## Context + +The recruiter-facing export had drifted across three earlier decisions: + +- [ADR-006](ADR-006-playwright-first-pdf-export.md) initially picked Playwright/Chromium for PDF rendering with a ReportLab fallback. +- ADR-006 was superseded informally by a WeasyPrint-based pipeline once the local Windows runtime made Playwright subprocess startup unreliable. +- The frontend kept exposing both a "Download Markdown" and a "Download PDF" button — Markdown was useful as an editing intermediate but produced confusing artifacts in the wild because users would paste raw `**bold**` syntax into application portals that don't render Markdown. + +Two new product needs forced a fresh decision: + +- The conversational resume builder ([ADR-016](ADR-016-conversational-llm-resume-builder.md)) needs an export from a draft profile that doesn't yet have a JD; the existing artifact-export route assumed a `TailoredResumeArtifact` produced by the agentic workflow. +- Recruiters consistently asked for a DOCX so they could paste sections into their ATS. PDF was acceptable as a final artifact but not as the only artifact — DOCX is what survives the next editing round. + +We also wanted both themes (`classic_ats` for ATS-safe single-column layouts, `professional_neutral` for editorial-leaning Georgia-bodied profiles) to read consistently across PDF and DOCX so a user picking a theme gets the same document in both formats. + +## Decision + +DOCX is now the primary artifact-export format alongside PDF. Markdown export is removed entirely. + +The pipeline: + +- `src/exporters.py` exposes `export_pdf_bytes(artifact, theme)` and `export_docx_bytes(artifact, theme)` for both `TailoredResumeArtifact` and `CoverLetterArtifact`. +- The DOCX renderer is built on `python-docx`, mirrors the structured PDF render decomposition (header, summary, skills, experience, projects, education, publications, certifications), and honours `artifact.section_order` so per-profile section ordering ([ADR-016 / Day 38 in DEVLOG](../../DEVLOG.md)) flows into both outputs. +- A shared palette resolver (`_RESUME_THEME_PALETTES`) keys `classic_ats` and `professional_neutral` to font family, accent color, and heading rules. Both PDF (HTML/CSS via WeasyPrint) and DOCX (python-docx style runs) pull from the same palette so the two formats stay visually aligned without hand-syncing. +- The `WorkspaceArtifactExportRequest` now accepts `export_format: "pdf" | "docx"`; the markdown branch is removed from `backend/services/artifact_export_service.py`. Frontend types and download buttons drop the markdown literal and add a DOCX button next to the PDF button. +- The resume builder gets its own `POST /workspace/resume-builder/export` endpoint that synthesizes a `TailoredResumeArtifact` from the builder session's draft profile (no JD, empty `target_role`, empty `change_log`, empty `validation_notes`, `section_order` from `compute_section_order(candidate_profile)`), then dispatches through the same `export_pdf_bytes` / `export_docx_bytes` path. Auth-gated like the other resume-builder routes. + +WeasyPrint stays as the PDF renderer (no change from the post-Playwright state). + +## Alternatives Considered + +### 1. Keep Markdown export and add DOCX +Rejected. Three formats means three code paths to maintain, three sets of theme styling, three columns of UI. The Markdown intermediate isn't a recruiter-facing artifact — users were copy-pasting raw syntax into application portals. The structured `TailoredResumeArtifact` schema already gives us everything Markdown was carrying, so the intermediate has no remaining purpose. + +### 2. Convert PDF to DOCX via pandoc / libreoffice headless +Rejected. Adds a heavy dependency on the deploy host (LibreOffice install or pandoc binary) and the conversion fidelity for layout-rich documents is poor. A native `python-docx` renderer over the structured artifact gives us deterministic output that we can theme to match the PDF. + +### 3. DOCX only, drop PDF +Rejected. Some recruiters specifically want a finished PDF for the application portal upload step. Both formats have a real use case. + +### 4. One renderer per surface (resume vs cover letter), no shared palette +Rejected. Two formats × two artifacts × two themes = four renderers. A shared palette resolver collapses it back to two renderers (PDF + DOCX), each parameterized by theme + artifact type. + +## Consequences + +### Positive + +- Recruiters get the format they actually edit in. DOCX exports survive the next round of changes without manual re-formatting. +- The shared palette resolver means a theme tweak (color, font, heading style) lands in both PDF and DOCX by editing one mapping. +- The resume builder finally has a download surface that doesn't require a JD upload — first-class exit point at "Generate base resume" with theme picker + format buttons. +- Removing Markdown export simplified the artifact viewer, the export hook, and the backend dispatch table. +- The structured-artifact schema (`TailoredResumeArtifact` / `CoverLetterArtifact`) is now load-bearing for export, which means schema changes are explicit rather than implicit through Markdown formatting. + +### Negative + +- `python-docx` is now a runtime dependency. Lightweight (~500 KB), but one more thing to keep up to date. +- DOCX visual fidelity in LibreOffice is a known weak spot; we test in Microsoft Word and Google Docs, document LibreOffice as nice-to-have but not blocking. +- The resume-builder export path duplicates a small amount of artifact-synthesis logic that the agentic workflow does naturally. Acceptable for now because the synthesis is straightforward; revisit if more "non-workflow exports" land. + +## Follow-Up + +- Supersedes [ADR-006](ADR-006-playwright-first-pdf-export.md) and the implicit Markdown export contract. +- Track DOCX rendering issues in the public bug tracker so we can prioritize Word vs Google Docs vs LibreOffice fidelity work. +- If a third theme lands, extract the palette resolver into a typed `ThemeSpec` so themes don't drift across renderers. diff --git a/docs/adr/ADR-016-conversational-llm-resume-builder.md b/docs/adr/ADR-016-conversational-llm-resume-builder.md new file mode 100644 index 0000000..252e2b2 --- /dev/null +++ b/docs/adr/ADR-016-conversational-llm-resume-builder.md @@ -0,0 +1,83 @@ +# ADR-016: Conversational LLM Resume Builder + +- Status: Accepted +- Date: 2026-05-07 + +## Context + +The earlier resume-onboarding flow had two paths: + +1. Upload an existing resume (PDF / DOCX / TXT) — the deterministic + LLM-hybrid parser extracted a candidate profile. +2. Manually fill a structured form for users without a resume yet — large textareas keyed to candidate-profile fields. + +Path (2) had real friction: + +- Users with no resume usually don't know what to put in fields like "experience bullets" or "education entries". They can describe a project conversationally but freeze when the form asks for structured data. +- The form expected complete answers per field; partial answers (e.g., a job title without dates) didn't survive the form's validation, so users would lose progress. +- Adding new optional sections (Projects, Publications) meant adding more fields to the form, which made the UI longer and more intimidating, not shorter. +- Students and early-career profiles needed a different shape than seniors (Education before Experience), which the static form couldn't express. + +We wanted a path that worked the way users naturally describe their background — through a conversation — while still landing the same `CandidateProfile` schema the rest of the workflow consumes. + +## Decision + +Replace the structured form with an LLM-led chat. + +The shape: + +- A `resume_builder_sessions` Supabase row tracks one in-progress draft per user, persisted across reloads. +- The user enters a chat surface inside the workspace's "Build with assistant" tab. The LLM asks questions one at a time, accepts free-form answers (multiple fields per turn supported), backtracks when the user corrects a previous answer, and renders a live "field completeness" rail so the user sees what's still missing. +- After every turn, a schema-validated extraction step pulls structured data out of the conversation and updates the session payload. Schema validation failures are silent — the LLM just asks a clearer follow-up. +- A separate **structuring pass** runs at "Generate base resume" time: + - buckets the flat `skills` list into named categories (`Languages & Tools`, `ML/DL Frameworks`, `Cloud & Infrastructure`, etc.) so the rendered resume groups skills by family + - expands thin one-liner summaries into full paragraphs (the LLM intake produces concise answers; the structuring pass spends a slightly bigger token budget to give the rendered resume a credible professional summary) + - recovers a full name when the LLM intake drops a surname mid-conversation + - emits `compute_section_order(candidate_profile)` so students lead with Education / Projects, academics with Publications, and seniors with Experience after Skills +- The structuring output is cached on the session row so a re-export doesn't re-run the LLM. +- The whole surface is gated to authenticated users (matching the resume-builder LLM auth gate the rest of the product uses). +- Session rows have a 7-day TTL with active-user refresh — every save extends `expires_at`. A `pg_cron` job (`cleanup-expired-resume-builder-sessions`) hard-deletes expired sessions every 5 min; RLS also filters expired rows so a user past their TTL doesn't see stale state. + +The download exit lives directly under the chat surface: theme picker (`classic_ats` / `professional_neutral`) + Download PDF / Download DOCX buttons that call `POST /workspace/resume-builder/export` ([ADR-015](ADR-015-docx-first-artifact-export-with-theme-palette.md)). + +## Alternatives Considered + +### 1. Keep the structured form, add Projects + Publications as optional fields +Rejected. Doesn't solve the underlying "users freeze on structured data entry" problem; just makes the form longer. + +### 2. Single-shot LLM ("paste anything, we'll structure it") +Rejected. Users without a resume usually don't have a prepared blob of self-description. The conversational shape forces a small, well-scoped question per turn, which matches how the user actually thinks about their background. + +### 3. Form-first with an LLM "fill missing fields" assist button +Considered. Combines the worst of both — users still face the form first, and the assist button turns into a fallback rather than the primary path. The conversational flow is a clearer product story. + +### 4. Build the structuring logic into the chat turn itself +Rejected. Each chat turn is on the user's perceived latency budget — every extra LLM call inside a turn slows the conversation. The structuring pass runs at "Generate base resume" time when the user is already waiting for an artifact, so the latency is acceptable there. + +## Consequences + +### Positive + +- Users without a resume can complete a builder session in 5–10 minutes of natural conversation instead of 30+ minutes of form anxiety. +- Backtracking works — saying "actually, my second job was at a different company" updates the session without losing the rest. +- Per-profile section ordering means the rendered resume looks correct for students, academics, and seniors without UI configuration. +- The structuring pass is the only place skills get bucketed and summaries get expanded, so a single quality-runner (`resume_builder_quality_runner.py`) can evaluate the whole content-quality story. +- Drafts survive 7 days of inactivity; an active user who saves any change extends the TTL. +- The exit ramp (download PDF/DOCX from the builder directly) means a user who's just here to make a base resume gets the artifact without needing to upload a JD. + +### Negative + +- Each chat turn costs an LLM call. We mitigate by: + - using a smaller / cheaper model for the chat itself + - reusing OpenAI's response-id continuation so the conversation context is server-side + - only running the heavier structuring pass at export time, not per-turn +- Schema-validated extraction can drop user content silently if the LLM produces malformed structured output. Mitigated by: + - a `Tier-3` quality runner that pins extraction success rates across fixtures + - the LLM intake prompt explicitly asking for "raw text now, structured later" so the structuring pass can recover + - regex fallback inside the structuring pass for the common "drop the surname" failure mode +- The conversational UX puts more weight on prompt quality. The `resume_builder_quality_runner.py` (Tier-3) pins this so prompt edits land with measurable evidence rather than vibes. + +## Follow-Up + +- Track LLM cost per completed session; cap heavy structuring calls per user per day. +- If users report the chat going off-topic, add a "stay on resume" guard prompt before each turn. +- See [ADR-015](ADR-015-docx-first-artifact-export-with-theme-palette.md) for the export pipeline this builder feeds into. diff --git a/docs/adr/ADR-017-workspace-assistant-state-aware-context.md b/docs/adr/ADR-017-workspace-assistant-state-aware-context.md new file mode 100644 index 0000000..608a7db --- /dev/null +++ b/docs/adr/ADR-017-workspace-assistant-state-aware-context.md @@ -0,0 +1,100 @@ +# ADR-017: Workspace Assistant — Ungated and State-Aware Context + +- Status: Accepted +- Date: 2026-05-08 + +## Context + +The workspace assistant chat (the floating FAB at the bottom-right of the workspace) used to be **locked until the first analysis run had completed**. The reasoning at the time was reasonable: the assistant grounds answers in the workspace package, so without a workspace there's "nothing to ground in" and the answers would be generic. + +In practice this had two failure modes: + +1. **Most user questions are product-help, not application Q&A.** Users opened the chat to ask "how do I use this?", "where do I upload my resume?", "what's step 03 for?" — questions the assistant could answer well even without a workspace, because the backend's `AssistantService.answer_product_help` path is designed for exactly that case. The lock pushed those questions out of the app entirely. +2. **The assistant was blind to pre-analysis state even when a user did have data.** The only context payload was `workspace_snapshot` — which the frontend set to `analysisState` — and that stayed `null` until an analysis ran. Even when the user had a parsed resume, a parsed JD, or a search history, the assistant didn't see any of it. So when a user asked "what should I do next?" with a parsed resume but no JD, the answer was generic boilerplate instead of "paste a JD and run analysis." + +We wanted the assistant available from the very first visit AND aware of the live workspace state, with the latter being the bigger win. + +## Decision + +Ungate the assistant chat and thread a compact projection of the live workspace state through every query. + +### Ungating + +Three lockup surfaces removed in one pass: + +- The `requiresWorkspaceRun` boolean that swapped the panel's footer form for a "Assistant unlocks after your first workspace run" block. +- An early-return guard inside `WorkspaceShell.submitAssistantQuestion` that surfaced a "Run the AI analysis first…" warning notice instead of submitting. +- An `assistantUnlocked` prop on the command palette that hid recent-question history. + +The remaining `requiresWorkspaceRun` prop became cosmetic-only and was renamed to `hasWorkspaceContext` to reflect what it actually drives now: contextual copy in the panel header sub-line, the empty state, and the textarea placeholder. + +### State context payload + +Every assistant query now includes a `workspace_state` object alongside the existing `workspace_snapshot`: + +```ts +type WorkspaceStateContext = { + current_step: "resume" | "jobs" | "jd" | "analysis"; + has_resume: boolean; + resume_summary: { name; location; skills_count; experience_entries_count; has_certifications } | null; + has_jd: boolean; + jd_summary: { title; location; hard_skills_count; soft_skills_count; must_haves_count } | null; + has_analysis: boolean; + saved_jobs_count: number; + last_search_query: string | null; +}; +``` + +Counts and identity only, no raw resume text or JD body. Built directly from existing React state in `submitAssistantQuestion` — no new server-side store, no extra round-trip. Backend's `WorkspaceStateContextModel` (extra="forbid") validates the shape; the workspace service folds it into the `app_context` dict that reaches `AssistantService`, where the existing `**(app_context or {})` spread surfaces it inside the prompt's JSON-blocked `product_context`. + +The `workspace_snapshot` field stays as-is for full-fidelity application Q&A once an analysis has run; `workspace_state` covers the pre-analysis gap. + +### System-prompt guidance + +A shared `_WORKSPACE_STATE_GUIDANCE` block was added to both `build_assistant_prompt` (JSON-contract path) and `build_assistant_text_prompt` (streaming prose path) so behavior is identical regardless of which call site uses which. The block teaches the model: + +1. **The `workspace_state` shape**, field by field, with explicit semantics. `experience_entries_count` is the number of work entries on the resume, NOT years of experience — the original name `experience_count` led the LLM to answer "how many years?" with the entry count. Renamed everywhere with a sentence in the prompt explicitly disambiguating. +2. **Step-number mapping** (01=Resume, 02=Job Search, 03=Job Detail, 04=Analysis) so questions like "what's step 03 about?" get the right answer instead of a guess. +3. **Auth contract** — the workspace requires sign-in; signed-out users get redirected to the landing page; "can I do X without signing in?" is always NO for any in-workspace action. +4. **Eight rules** for using the state: always check before answering "what's next?"; never invent skills/jobs/scores when the corresponding flag is false; map `current_step` + flags to the very next concrete action; translate raw counts into prose ("we found 27 skills" not "skills_count: 27"); be concise (1–3 sentences for product help). + +### Product-knowledge refresh + +While ungating, refreshed `src/product_knowledge.py` to ground truth — 12 documents covering the four-step flow, both resume intake modes (Upload + Build with assistant), all four ATS sources (Greenhouse, Lever, Ashby, Workday), the six supervised-pipeline agents, PDF + DOCX exports with theme palette, the saved-workspace 24-h TTL, the command palette, the floating assistant itself, the cover letter artifact, and the quota model. Several earlier documents referenced retired surfaces (e.g. "Manual JD Input" instead of "Job Detail," Markdown export which was removed in 2026-05); those were rewritten or replaced. + +## Alternatives Considered + +### 1. Save chat history to a Supabase `assistant_messages` table and ground answers on it +Rejected for now. Useful for cross-device persistence and recall ("what did I ask last week?"), but it does not improve answer quality on the current turn and adds round-trips. Independent of this ADR; can be revisited later. + +### 2. Build a Supabase `workspace_state` table that the backend reads on every assistant query +Rejected. The state already lives in React memory on the client. A server-side store creates a sync problem (which is the source of truth?), adds a round-trip per query, and doesn't give us anything we don't already get by sending the projection inline. Latency-wise, a 500–800 byte JSON addition to the existing request payload is invisible compared to LLM streaming time. + +### 3. Inject `workspace_state` directly into the prompt's text instead of via the JSON `product_context` block +Rejected. The existing `assistant_context` JSON block already carries context cleanly; adding a parallel text channel would split the source of truth. Putting the state inside `product_context` reuses the same path the existing flags (`has_resume`, `has_tailored_resume`, etc.) already travel. + +### 4. Keep the gate but make the empty-state copy more useful +Rejected. The user's actual mental model is "I can chat with the assistant" — gating it on an action they may not have done yet is friction, not safety. The backend's `answer_product_help` path makes the gate redundant. + +## Consequences + +### Positive + +- Users can ask questions from the very first visit, including before they upload anything. +- "What should I do next?" returns three different, correct answers across the cold-start / mid-flow / ready-to-run personas instead of one generic answer for all three. +- The assistant proactively suggests next actions ("you've saved 2 roles — open one of those postings to move to the JD step") instead of describing the workspace abstractly. +- The cosmetic `hasWorkspaceContext` boolean drives panel copy that's contextual without adding a new gate. +- Field semantics are now explicit in the prompt so the model can't quietly conflate counts with durations. +- Battle-test verified: 47/51 (92%) across three persona × ~17 questions, with 0 outstanding correctness failures after the entry-count and step-number bugs were fixed. + +### Negative + +- Slightly more tokens per query (~150 prompt tokens for the guidance block + ~500–800 bytes of payload). Cost impact is minimal on the assistant model; latency-wise the ~30–80 ms TTFT increase is invisible inside the streaming generation time. +- The assistant now answers a wider range of questions, which means the system prompt has to handle more edge cases (off-topic questions, account-deletion requests, unverified pricing claims). Mitigated by the existing scope-narrowing language at the top of the prompt and verified by battle-test refusals. +- Product-knowledge maintenance is now a real ongoing job — the ground-truth refresh in this ADR is current as of 2026-05-08, but the index will go stale as features ship. Owner: whoever ships a new workspace surface should update `src/product_knowledge.py` in the same PR. + +## Follow-Up + +- Add an `assistant_quality_runner` fixture for the `cold_start` / `mid_flow` / `ready_to_run` personas so future system-prompt edits land with measurable evidence rather than vibes. +- Consider persisting chat history to Supabase for cross-device recall (independent of this ADR; not blocking). +- Track LLM cost per assistant turn against the workspace cost ceiling; the assistant is now reachable from a wider user surface than before, so usage patterns may shift. diff --git a/docs/adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md b/docs/adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md new file mode 100644 index 0000000..7b451e8 --- /dev/null +++ b/docs/adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md @@ -0,0 +1,145 @@ +# ADR-018: Three-Layer LLM Retry and Per-Agent Fallback Isolation + +- Status: Accepted +- Date: 2026-05-08 + +## Context + +LLM-driven flows in the workspace (the supervised analysis pipeline, the assistant chat, the resume parser, the JD parser, the JD summary, every workflow agent) all eventually call `OpenAIService.run_json_prompt` or `run_text_stream`, which call `self._client.responses.create(...)`. The OpenAI Python SDK is configured with `max_retries=2`, so the SDK transparently retries on its own list of transient failures (connection drops, timeouts, 5xx, 429-with-Retry-After). + +After those 2 retries exhaust, the SDK raises and our application code's behavior was: + +- For LLM calls inside the **assistant chat:** caught and turned into a `delta` of "(deterministic answer)", which felt fine because the user could just re-ask. +- For LLM calls inside the **supervised analysis pipeline:** caught at the orchestrator level and the WHOLE PIPELINE was re-run with `openai_service=None`. The user got every artifact (Tailoring, Review, Resume Generation, Cover Letter) in deterministic mode — even the agents that would have succeeded on the LLM path. A single bad packet during one agent's call meant losing LLM-quality output across all four artifacts. + +This was strictly worse than necessary because: + +- Most "bad packets" are transient — one more SDK call probably succeeds. The SDK already does this twice, but its own list is conservative; some failures slip through. +- Even when a particular agent really can't get LLM output, the OTHER agents in the pipeline aren't affected. There's no reason for the Forge agent's bad day to compromise the Cover Letter agent. +- The whole-pipeline downgrade meant deterministic Tailoring → deterministic Review → deterministic Resume Generation → deterministic Cover Letter. That's four artifacts of degraded output instead of one. + +We wanted the orchestrator's resilience to be **layered** — give the LLM call multiple shots before the agent gives up, AND keep one agent's failure from affecting the others. + +## Decision + +A three-layer retry stack on top of per-agent fallback isolation. + +### Layer 1 — SDK retries (existing) + +`self._client = OpenAI(api_key=..., timeout=120.0, max_retries=2)`. Unchanged. Catches the obvious transient cases — connection refused, DNS failure, request timeout, 5xx server errors, 429 rate limits with `Retry-After` honored. Burned twice silently. + +### Layer 2 — App-level retry on `responses.create` + +A new `OpenAIService._create_response_with_app_retry` helper wraps the single `self._client.responses.create(**payload)` call. After the SDK's 2 retries exhaust and the SDK finally raises, we try ONE more time on a **tight allow-list**: + +```python +_RETRYABLE_OPENAI_EXCEPTIONS = ( + APIConnectionError, + APITimeoutError, + InternalServerError, +) +``` + +NOT retried at this layer: +- 4xx client errors (`BadRequestError`, `AuthenticationError`, `NotFoundError`, `UnprocessableEntityError`) — our payload is wrong; retry won't help, just adds latency. +- `RateLimitError` — the SDK already handled retry-after; if it gave up, the user is consistently throttled and we shouldn't pile on. +- Content-policy violations — same reasoning as 4xx. + +400 ms delay between attempts. New `openai_request_app_retry` log event with `error_type`, `details`, and `retry_delay_seconds` for production observability. + +For streaming (`run_text_stream`), retry covers only the **initial stream creation**. Once we've started yielding deltas, we can't retry without confusing the consumer (partial deltas already left the building); mid-stream failures propagate as before. + +For the existing output-budget retry helper (`_retry_with_higher_output_budget`, fires when a response was truncated due to insufficient `max_output_tokens`), the budget-retry call now also routes through `_create_response_with_app_retry`, so a token-grow retry that hits a transient failure also gets one extra shot. + +### Layer 3 — Per-agent retry inside the orchestrator + +The `run_agent_step` helper in `ApplicationOrchestrator._run_pipeline` now wraps each agent's `.run(...)` call in its own retry. If the agent raises `AgentExecutionError` (which is what `OpenAIService` re-raises after its layers exhaust, AND what some agents raise for semantic failures like missing required fields after the budget retry), we wait 400 ms and retry the agent's full run once. + +Only fires in `mode="openai"`. In deterministic mode, the agents short-circuit to their internal `_fallback()` paths and never raise, so the retry is a no-op. + +Only `AgentExecutionError` is retried. Other exceptions (bugs in our own code, contract violations) propagate immediately because they wouldn't change on retry. + +### Per-agent fallback isolation + +When an agent's two LLM attempts both fail, instead of cascading the exception up to the orchestrator's whole-pipeline `try/except` (the old behavior), `run_agent_step` now runs that agent's deterministic fallback for THAT agent only. + +Each call site supplies a `deterministic_fallback_runner` lambda alongside the assisted runner: + +```python +tailoring_output = run_agent_step( + "tailoring", + lambda: tailoring_agent.run(candidate_profile, job_description, fit_analysis, tailored_draft), + deterministic_fallback_runner=lambda: TailoringAgent(None).run( + candidate_profile, job_description, fit_analysis, tailored_draft, + ), +) +``` + +The deterministic fallback runner constructs a fresh agent instance with `openai_service=None`. The agent classes already short-circuit to their internal `_fallback()` path when no service is configured, so this gives us the deterministic output for the failing agent without any agent-level refactor. Downstream agents receive the deterministic output as input and continue trying the LLM path themselves. + +The whole-pipeline fallback is now a **safety net** that fires only if a per-agent deterministic fallback ITSELF errors out — very unusual, since that would mean our own deterministic code is broken, not the LLM. + +### Mode reconciliation + +The `result.mode` field on `AgentWorkflowResult` historically meant "the LLM was used." With per-agent fallback isolation, partial use is now possible — three of four agents may run with the LLM and one may fall back. To keep the mode field honest: + +- Pipeline tracks `llm_success_count` and `per_agent_fallback_count` across all agent steps. +- After all agents finish, if a pipeline started in `mode="openai"` but `llm_success_count == 0` (every agent fell back per-agent), the result's `mode` is downgraded to `"deterministic_fallback"` and `model` flips to `"fallback"`. The first LLM error's `user_message` becomes the `fallback_reason`. This preserves the historical contract for consumers reading `mode` to detect a fully-deterministic run. +- A partial run (e.g. only Forge fell back) correctly keeps `mode="openai"` because the LLM did do useful work in 3 of 4 stages. + +### Coverage check + +Every `responses.create` call in the codebase now routes through `_create_response_with_app_retry`. By extension, all of the following inherit Layer 2 for free: + +- Resume parser (`resume_llm_parser_service.py`) +- JD parser (`jd_llm_parser_service.py`) +- JD summary (`jd_summary_service.py`) +- All four supervised-workflow agents (Tailoring, Review, Resume Generation, Cover Letter) +- The assistant chat (streaming and non-streaming paths) +- The output-budget retry helper + +## Alternatives Considered + +### 1. Add an unbounded retry loop with exponential backoff +Rejected. Compounding the SDK's 2 + an app retry × N = 4+ attempts means latency explodes during real outages, and retrying past the third attempt rarely changes the outcome for transient failures. The 400 ms fixed delay + single app retry is the right balance. + +### 2. Retry on every `Exception`, not the allow-list +Rejected. Retrying on `BadRequestError` (our payload is wrong) just adds latency without changing the outcome. The narrow allow-list of three transient SDK exception types matches how the SDK itself classifies retryable errors. + +### 3. Retry mid-stream by reconnecting and replaying deltas +Rejected. The consumer has already received and rendered partial deltas — there's no clean way to "rewind" and try again without confusing the UI. Mid-stream failures propagate; the SDK + app retries cover the initial-connection case which is the much more common transient. + +### 4. Whole-pipeline retry instead of per-agent retry +Rejected. Re-running every agent because one had a bad packet wastes 4×N tokens and 4×N seconds. Per-agent retry costs at most 1×N tokens and stays scoped to the failure. + +### 5. Per-agent retry with N>1 attempts +Considered. Up to 4 effective LLM calls per agent (SDK 2 + app 1 + per-agent 1) is already generous. Adding more would compound latency without clear evidence of additional recovery. Revisit if production telemetry shows we're losing recoverable failures at the per-agent layer specifically. + +### 6. Unified failure mode — drop deterministic fallbacks entirely and let agents fail loudly +Rejected. Deterministic fallbacks are the ground-truth-aware floor of what we can produce when AI is unavailable for any reason (auth issues, account quota, content policy, regional outage). Per-agent fallback isolation actually makes the deterministic path more useful, not less — it can now serve a single missing artifact instead of replacing four good ones. + +## Consequences + +### Positive + +- A single transient failure during the supervised pipeline now has up to 4 LLM call attempts before the agent gives up. Most "bad packets" recover invisibly. +- One agent failing no longer cascades to four deterministic outputs. The user keeps their LLM-quality Cover Letter even if Forge had a bad day. +- All LLM call sites in the codebase share the same retry contract — easier to reason about, easier to tune (the delay constant and exception allow-list live in one place). +- New `openai_request_app_retry` and `agent_run_retry` log events give us production visibility into how often the second attempt actually saves a run. We can tune the layers based on real telemetry. +- 17 new tests pin the contract — Layer 2 retries on the 3 allow-listed types and not on 4xx/auth; per-agent retry recovers a flaky agent; per-agent fallback isolates a failing agent; mode reconciles to deterministic when no LLM call succeeded. + +### Negative + +- More wall-clock time on a real outage. SDK 2 + app 1 + per-agent 1 = up to 4 round-trips × ~120 s timeout = a worst-case ~8 minutes if every layer hits timeout. Mitigated by the timeout itself (120 s is hard, no extra wait), the 400 ms inter-retry delays (small relative to the LLM call), and the fact that during an outage most failures fail fast (connection refused) rather than timing out. +- Slightly more code complexity in `OpenAIService` and `_run_pipeline`. Mitigated by the helper pattern (`_create_response_with_app_retry`, `run_agent_step`) that keeps the retry logic in one place per layer. +- Deterministic fallback paths in each agent (`_fallback()`) are now load-bearing in a different way — they're called per-agent during a partial outage, not just during a full no-service run. We already had Tier-2 quality runners pinning the deterministic outputs against fixtures; those continue to enforce that the deterministic floor stays reasonable. + +## Follow-Up + +- Track the new log events in production: + - `openai_request_app_retry` count vs `openai_request_completed` count → SDK exhaustion rate. + - `agent_run_retry` count vs `agent_run_completed` count → per-agent retry trigger rate. + - `agent_run_per_agent_fallback` count → how often we're falling back per-agent. + - `orchestrator_completed` events with `llm_success_count == 0` → fully-deterministic runs. +- If `agent_run_retry` shows a high recovery rate (>50%), consider raising the per-agent retry budget to 2. +- If `agent_run_per_agent_fallback` is dominated by one specific agent, investigate whether that agent's prompt or schema needs tightening. diff --git a/docs/adr/ADR-019-independent-step-navigation.md b/docs/adr/ADR-019-independent-step-navigation.md new file mode 100644 index 0000000..d8d7288 --- /dev/null +++ b/docs/adr/ADR-019-independent-step-navigation.md @@ -0,0 +1,80 @@ +# ADR-019: Independent Step Navigation in the Workspace + +- Status: Accepted +- Date: 2026-05-08 + +## Context + +The workspace's step rail enforced the following gates on which steps were navigable: + +``` +resume → always available +jobs → available once a candidate profile exists +jd → available once a candidate profile exists OR a job is selected +analysis → available once both resume + JD are parsed +``` + +The rail surfaced "Upload a resume to unlock" tooltips on Job Search and Job Detail when their gates failed, and the corresponding rail buttons were `disabled`. + +In practice the gates on Job Search and Job Detail caused friction: + +- Users frequently arrive with a job they've already heard about — a recruiter reach-out, a friend's referral, a posting URL they want to evaluate. They want to paste the JD and look at the parsed requirements before deciding whether to upload a resume at all. +- Users sometimes want to browse the live listings cache as a discovery surface — "what ML engineering roles are open right now at Stripe / Pinterest / Anthropic?" — without committing to a resume upload first. +- The "Upload a resume to unlock" tooltip read as a hostile gate, not as guidance. The rail is for navigation; navigation gating belongs to the destination page if at all. + +The Analysis step's gate is different: it can't actually run without both inputs (the supervised pipeline reads from both the parsed resume and the parsed JD). Gating it on the rail is honest. + +## Decision + +Drop the rail gates on Job Search and Job Detail. Keep the Analysis gate. + +The new gating model: + +``` +resume → always available +jobs → always available +jd → always available +analysis → available once both resume + JD are parsed +``` + +Specifically: + +- `stepReady.jobs` and `stepReady.jd` in `WorkspaceShell.tsx` are now literal `true`. The corresponding entries in the `lockReason` tooltip map are empty strings. +- The "Upload a resume first" fallback `sub` text on the `nav-jobs` and `nav-jd` command-palette entries was removed — it was dead code now that those steps are never gated. +- The `AnalysisRunner.tsx` page-level "Upload a resume to proceed" affordance stays as the honest enforcement point for the only step that genuinely requires both inputs. The rail-level lock on Analysis is now a hint, not a hard wall (the page itself surfaces what's missing). + +The actual workflow behavior is unchanged — Job Search has always called the cached-jobs RPC without needing a candidate profile, JD parsing has always worked on the JD text alone, and Analysis still won't run without both inputs. This ADR is purely about removing UI gates that pushed users away from useful pages. + +## Alternatives Considered + +### 1. Keep the gates but soften the tooltip copy +Rejected. The gate itself was the problem, not the tooltip phrasing. A "softer" tooltip on a button you can't click is worse, not better. + +### 2. Allow navigation but show a "Step requires a resume" warning at the top of Job Search / Job Detail +Rejected. The pages don't actually require a resume to function — Job Search lists open roles regardless, and Job Detail parses a JD on its own. A warning would be lying about what the user can do. + +### 3. Remove all gates including Analysis +Rejected. Analysis can't run without both inputs; allowing the user to navigate there only to see "Upload a resume to proceed" with no other affordances is worse than the rail-level visual hint that the step is gated. The current AnalysisRunner page tells the user exactly what's missing — that's the right experience. + +## Consequences + +### Positive + +- Users with a JD they want to evaluate can paste it without any friction. The parsed-JD hero gives them an immediate read on whether the role is worth tailoring for. +- Users who want to browse open ML / data / engineering roles without uploading a resume can do so directly. The cached-jobs cache (Greenhouse / Lever / Ashby / Workday, ~12k roles) is a legitimate discovery surface in its own right. +- The rail reads as navigation, not enforcement — matches user expectation. +- One fewer "locked" affordance on the workspace, which reduces overall UI friction and makes the rail feel like a sequence of options rather than a gauntlet. + +### Negative + +- Users may use Job Search or Job Detail without ever uploading a resume, then be confused that Analysis still doesn't run. The Analysis page itself surfaces what's missing, but a user who doesn't reach Analysis won't see that. Acceptable trade — they can still get value from the parsed JD on its own, and the topbar's persistent stat pills ("Resume · not uploaded") signal what's missing. +- The supervised analysis pipeline still has its own gates (the Analysis page's "Upload a resume" affordance) — those weren't changed. So a user who tries to run analysis without a resume gets a clear "missing input" surface. + +## Follow-Up + +- Consider adding a "you've parsed a JD without a resume" callout on the Analysis page that nudges to Step 01. +- Watch the saved-jobs / Job Search analytics for users who browse extensively without uploading. If conversion lift on resume upload is meaningful, no further action; if it tanks, revisit whether a softer prompt on those pages is warranted. + +## Related + +- [ADR-017](ADR-017-workspace-assistant-state-aware-context.md): the workspace assistant now gets `current_step` and the `has_resume` / `has_jd` flags via its state-context payload, so it can give honest answers when a user asks "what should I do next?" while standing on Job Search with no resume. diff --git a/docs/adr/ADR-020-tier-resolution-via-single-shim-function.md b/docs/adr/ADR-020-tier-resolution-via-single-shim-function.md new file mode 100644 index 0000000..981a940 --- /dev/null +++ b/docs/adr/ADR-020-tier-resolution-via-single-shim-function.md @@ -0,0 +1,80 @@ +# ADR-020: Tier Resolution via a Single Shim Function + +- Status: Accepted +- Date: 2026-05-15 + +## Context + +The Day 42 tier-enforcement series wired eight quota gates (`tailored_applications`, `premium_applications`, `resume_builder_sessions`, `assistant_turns`, `resume_parses`, `job_searches`, `saved_jobs`, `saved_workspaces`), a tier-aware retention sweeper for `saved_workspaces`, and a tier-aware premium model router. Each of these surfaces needs to answer the same question — *what subscription tier does this user have?* — at request time. + +Two facts shaped the decision: + +1. **Payments aren't shipping in the same week as enforcement.** The tier-enforcement series shipped to `main` and deployed; the Lemon Squeezy integration (Day 43) lives on a separate branch and goes live after the LS variant IDs are configured. Until then, every authenticated user is on the Free tier by definition — there are no paid subscribers because there's no checkout. +2. **Once payments do ship, we need every gate to honour the new tier on the same deploy.** Anything less means a partial-rollout window where some surfaces gate on the real tier and others still gate on `"free"`, which is the worst possible state for a billing surface. + +We needed a structural answer to "where does tier come from?" that lets enforcement code ship today against a static Free baseline AND flips cleanly to live subscriptions tomorrow. + +## Decision + +A single function — `backend.tiers.resolve_user_tier(app_user: AppUserRecord | None) -> Literal["free", "pro", "business"]` — is the canonical entry point for tier resolution. Every gate, the model router, the retention sweeper, and the `/workspace/quota` snapshot all call it. Today the body is: + +```python +def resolve_user_tier(app_user: AppUserRecord | None) -> Tier: + _user_id = getattr(app_user, "id", None) + return "free" +``` + +— it accepts the `app_user` (so the call-site signature is already what the live version will need), touches `app_user.id` defensively, and returns the literal `"free"`. When payments go live (Day 43, commit `1b8cf95`), the body is rewritten to consult the `aijobagent_subscriptions` table: + +```python +def resolve_user_tier(app_user: AppUserRecord | None) -> Tier: + user_id = getattr(app_user, "id", None) + if not user_id: + return "free" + active = subscriptions_store.find_active(user_id=user_id) + if active is None or active.current_period_end <= datetime.now(timezone.utc): + return "free" + return active.tier +``` + +No gate, no router, no sweeper, no test fixture, no snapshot endpoint changes. The Stripe / Razorpay swap is identical in shape: rewrite the body, leave the signature intact. + +## Alternatives Considered + +### 1. Per-gate tier checks inline at each call site +Rejected. Eight gates × one tier check each = eight places that need to learn about subscriptions on the payment cutover. The model router and retention sweeper are two more. That's ten chances for an inconsistency to ship — and worse, the consistency is *invisible* (no compile error catches a forgotten call site, only a production overage does). + +### 2. Decorator pattern (`@require_tier("pro")` on each endpoint) +Rejected for two reasons. First, the tier requirement is rarely binary — `/workspace/analyze` accepts everyone but charges different counters based on tier (`tailored_applications` for all, `premium_applications` only for Pro+ when `premium=True`). A decorator that resolves to "allow / deny" doesn't model that. Second, decorators bind tier-knowledge to *route* handlers, but the same tier needs to reach the model router and the per-pipeline construction-time `model_overrides` (see ADR-022). Threading the decorator's result through to deep call paths means lifting it to a request-context attribute anyway, at which point it's just a worse version of the shim. + +### 3. Class-based `TierResolver` injected into the request context +Considered. Object-oriented version of the same idea — DI a resolver, swap its implementation on the cutover. Adds construction wiring at every entry point without changing the substantive call shape. Rejected on the basis that a free function with one body to swap is the simplest thing that could possibly work, and we have no existing DI container to plug a resolver into. Revisit if we ever grow multiple resolution strategies (e.g. tier-by-tenant for B2B), but that's not on the roadmap. + +### 4. Compute tier eagerly at sign-in and stash on `AppUserRecord.plan_tier` +Considered, partially rejected. The `app_users` table does have a `plan_tier` column, populated at signup. We could trust it as the source of truth — but stale state is exactly the failure mode subscriptions introduce. A user who cancels at 23:59 and runs a workflow at 00:01 has the new tier ("free") even though their session still carries the old `plan_tier == "pro"`. The Day 43 store-backed resolver consults `current_period_end` on every call, which is correct by construction; the `plan_tier` column becomes a denormalized hint we may or may not refresh. Centralizing resolution in the shim means we get the live answer for free without per-call-site decisions about whether to trust the cache. + +## Consequences + +### Positive + +- Payment cutover is a one-function rewrite. No call sites change, no tests change (beyond the resolver's own), no signatures change. +- Test fixtures that need to simulate a Pro user just monkeypatch `resolve_user_tier`. We already do this in `test_workspace_retention.py` and `test_tier_aware_workflow_model.py` to exercise the Pro / Business code paths against today's Free-only resolver. +- The `app_user` argument is accepted today even though it's unused; this keeps the call-site code identical pre- and post-cutover, which means git blame stays clean and reviewers don't have to context-switch on the signature. +- The tier *type* is a `Literal` — adding a new tier (`"enterprise"`) is a tiny PR that updates the type alias, `TIER_CAPS`, the retention table, and the resolver body. The type checker catches every missing branch. + +### Negative + +- Every gate pays a tier resolution call per request. Today that's one Python branch; post-cutover it's a Supabase round-trip per gated request. The `/workspace/quota` snapshot endpoint calls it once per page mount, and each workspace action that's gated calls it once. We avoid stampedes by reading the active subscription once per request (memoize on the FastAPI request scope when the live resolver lands). +- A bug in the resolver body affects every gate simultaneously. Mitigated by the tier-specific Pro / Business test paths already exercising the wiring against a patched resolver — the patch surface IS the resolver, so any regression in its behaviour is caught. +- The shim hides the fact that subscriptions exist from the call sites. New gates added in the future need to *not* re-derive tier from `app_user.plan_tier` directly — they need to call the resolver. This is a code-review checklist item. + +## Follow-Up + +- When Day 43 (Lemon Squeezy) lands and the resolver body is rewritten, add a request-scoped memoization layer so a single `/workspace/analyze` request doesn't issue N tier lookups for N gated steps. +- Linter rule (or grep guard in CI): forbid direct reads of `app_user.plan_tier` outside `backend/tiers.py` and `backend/subscriptions.py`. The `plan_tier` column stays as a denormalized hint for the account popover; it isn't load-bearing for enforcement. + +## Related + +- [ADR-021](ADR-021-atomic-quota-with-refund-on-failure.md): the quota helper called by every gate; together with the resolver, these are the two functions every enforcement surface routes through. +- [ADR-022](ADR-022-tier-aware-model-selection-via-constructor-injection.md): the model router consumes `resolve_user_tier`'s output at construction time. +- [ADR-023](ADR-023-lemon-squeezy-merchant-of-record-for-v1.md): the payment processor whose subscription rows the post-cutover resolver consults. diff --git a/docs/adr/ADR-021-atomic-quota-with-refund-on-failure.md b/docs/adr/ADR-021-atomic-quota-with-refund-on-failure.md new file mode 100644 index 0000000..f26bc52 --- /dev/null +++ b/docs/adr/ADR-021-atomic-quota-with-refund-on-failure.md @@ -0,0 +1,90 @@ +# ADR-021: Atomic Quota with Refund-on-Failure + +- Status: Accepted +- Date: 2026-05-15 + +## Context + +The pre-Day-42 quota path was inherited from the Streamlit era: `src/quota_service.py` aggregated `usage_events` rows for the current UTC day and pre-checked the count against an env-driven daily limit before each assisted call. This pattern had two structural problems for the new tier-enforcement matrix: + +1. **It's racy.** Two concurrent `/workspace/analyze` calls from the same user both see `count = N` in the SELECT, both decide they're under the cap, both execute their workflow, and both INSERT their own `usage_events` row. The user gets `N + 2` requests on a cap of `N + 1`. The window is small (one round-trip) but the cap is per-month and concurrent runs from one user are realistic — two browser tabs, retried CTAs, etc. +2. **It can't distinguish "request happened" from "request succeeded".** Pre-check + post-increment makes the increment a single-direction commitment; once the workflow runs and fails, the count is consumed. A network blip during the Forge agent shouldn't burn the user's monthly `tailored_applications` credit, but the legacy pattern offered no rollback. + +The HelpmateAI quota path (which we draw a lot of structure from) keeps the pre-check + post-increment shape. AI Job Agent runs more concurrent agents per user (the supervised pipeline is four sequential agents per `/workspace/analyze`, plus parsers and search), so the racy window is more exposed AND the consequences of an irreversible increment are worse. + +## Decision + +A single SQL function does the cap check and the increment in one transaction; failures refund. + +### Atomic increment in SQL + +`public.increment_aijobagent_counter(p_user_id uuid, p_period_key text, p_counter_name text, p_cap integer, p_delta integer)` lives in `docs/sql/supabase-quota-counters.sql`. The function's body: + +1. For positive deltas: `SELECT count ... FOR UPDATE` on the existing row (or 0 if absent), then check `existing_count + p_delta > p_cap`. If so, `RAISE EXCEPTION 'aijobagent_quota_exceeded' USING ERRCODE = 'P0001', DETAIL = format('counter=%s cap=%s current=%s', ...)`. +2. `INSERT ... ON CONFLICT (user_id, period_key, counter_name) DO UPDATE SET count = greatest(count + p_delta, 0), updated_at = now() RETURNING count`. + +The `FOR UPDATE` + same-transaction INSERT-ON-CONFLICT serializes concurrent calls on the same row. Two workspace runs producing counts `N + 1` and `N + 2` respectively — never both `N + 1`. The SQL function is `SECURITY DEFINER` so the cap check can't be bypassed by a client; EXECUTE is granted *only* to `service_role` because the function takes `p_user_id` as a parameter and would otherwise let any signed-in user burn another user's quota by passing their UUID. + +### Python wrapper translates the P0001 + +`backend/quota.py::check_and_increment(counter_name, user_id, tier, *, lifetime=False)` resolves the cap from `TIER_CAPS[tier][counter_name]`, short-circuits when the cap equals `UNLIMITED` (no row is written; the table stays compact), and otherwise calls the RPC. The supabase-py wrapper surfaces the P0001 as an `APIError` whose message contains the SQL DETAIL string; we pattern-match `"aijobagent_quota_exceeded"` and re-raise as `src.errors.QuotaExceededError(counter, current, cap, reset_period, tier)`. + +### Single global 429 handler + +`backend/app.py` registers exactly one exception handler for `QuotaExceededError`. The handler returns a 429 with a fixed body shape (`detail`, `code: "tier_limit_exceeded"`, `counter`, `current`, `cap`, `reset_period`, `tier`). Every gate raises through this path; the frontend renders one upgrade-nudge component regardless of which counter fired. + +### Refund-on-failure + +`backend/quota.py::refund(counter_name, user_id, tier, *, lifetime=False)` calls the same RPC with `p_delta = -1`. The SQL function floors at zero on negative deltas (`greatest(count + p_delta, 0)`) and never invokes the cap check on a negative delta (the second `if p_delta > 0:` branch). The orchestrator's outermost try/except catches `AgentExecutionError` and similar pipeline failures, calls `refund("tailored_applications", ...)` (and `refund("premium_applications", ...)` if premium was opted in), then re-raises. Refunds are best-effort: a Supabase outage during refund logs and swallows so the caller can re-raise the original workflow exception — the user's account has already absorbed the increment, and a refund failure shouldn't mask the real error. + +### Period keys + +Period partitioning is on the application side: a `period_key` column lets the call site write to either the current month (`current_period_key()` returns `"YYYY-MM"`) or a literal `"lifetime"`. The Free-tier `resume_builder_sessions` cap (1 session ever) uses `lifetime=True`; the same counter on Pro / Business (3 / 15 per month) uses the default monthly partition. No scheduled reset job — the next month's first increment writes a fresh row with `count = 1`. + +### In-memory fallback for tests + +When Supabase isn't configured (local dev, CI without secrets), `backend/quota.py` falls back to a process-local `_InMemoryQuotaBackend` that mirrors the SQL semantics: same atomicity guarantees within a single process via a `threading.Lock`, same `_QuotaExceededAtBackend` exception type that's translated identically. Production must run with Supabase — the in-memory backend is not safe under concurrent workers (each worker has its own dict). + +## Alternatives Considered + +### 1. Keep the pre-check + post-increment pattern, add a retry on conflict +Rejected. Detecting the race requires a unique constraint *and* a way to roll back the workflow if the second insert fails — which means the workflow has to be idempotent enough to re-run, or we leak side effects (LLM tokens spent, partial agent outputs). Atomic-at-SQL is strictly simpler than "make the whole workflow restartable". + +### 2. Optimistic locking with `UPDATE ... WHERE count = expected` +Rejected. Two-call shape (read, then conditional update) widens the racy window between calls and doesn't compose well with the FastAPI request lifecycle. The single `FOR UPDATE` + INSERT-ON-CONFLICT in the SQL function is the canonical PostgreSQL atomic-counter idiom. + +### 3. Sequence-backed counter instead of `count` column +Rejected. Sequences don't gap-fill, so a refund (decrement) wouldn't free the credit. Sequences also can't enforce a cap directly; the application would still need a pre-check. + +### 4. Charge after success only (no pre-increment at all) +Considered. Wait until the workflow returns successfully, then increment. Removes the refund machinery entirely but introduces a new race: ten concurrent workflows can all run "for free" before any of them increments, and the cap doesn't fire until the 11th request arrives — which by then has already been *accepted* on the server side. Atomic-increment before work + refund-on-failure is the right side of that trade. + +### 5. Persistent row-count counters in the `aijobagent_quota_counters` table +Rejected for `saved_jobs` and `saved_workspaces`. These caps are persistent (current row count vs cap, not period-keyed), and the row count *already lives* in the dedicated store table. Mirroring it into the quota table means two sources of truth and a sync problem. Instead, the gate calls `SavedJobsStore.count(user_id)` directly and compares against `TIER_CAPS[tier]["saved_jobs"]` before the insert. The `/workspace/quota` snapshot reads the row count from the same store for the UI indicator. The `aijobagent_quota_counters` table only holds the period-keyed counters. + +## Consequences + +### Positive + +- Concurrent runs from the same user can't breach the cap. The SQL function serializes them. +- Workflow failures don't burn the user's quota credit. A transient OpenAI outage that takes down `tailored_applications` for a single run gets refunded the next instant; the user retries and the second attempt re-increments. +- One global 429 handler means every gate's frontend treatment is identical. No per-counter response-shape skew. +- The same Python function and same SQL function support both monthly and lifetime periods via a kwarg — no parallel `lifetime_quota_counters` table. +- `UNLIMITED` short-circuits at the Python layer so the SQL function never sees the unlimited case; the table stays compact even for high-volume Pro / Business counters like `job_searches`. + +### Negative + +- Every gated request makes a Supabase round-trip. Latency adds ~30-80 ms per gate. The orchestrator's `/workspace/analyze` path passes through 2 gates max (`tailored_applications` + optionally `premium_applications`), so 2× 80 ms = 160 ms in the worst case — well inside acceptable for a pipeline that already takes 20-40 s of LLM time. +- Refunds add a second round-trip on failures. Best-effort by design: if the refund itself fails, the user has lost a credit. Mitigated by logging the failure with full context so we can manually refund from the dashboard if support gets a ticket. +- The in-memory fallback diverges from production behaviour under multi-worker test runs. We mitigate by running our test suite single-process; nobody is running pytest with `--numprocesses` against the in-memory backend. + +## Follow-Up + +- Add a Supabase metric on the `aijobagent_quota_counters` insert rate to catch unusual burn patterns (e.g. a script abusing the assistant turn cap). +- Periodic background reconciliation: walk `usage_events` totals against `aijobagent_quota_counters.count` for the current month — if they ever diverge by more than a small noise threshold, something is bypassing the gate. +- When Day 43 (Lemon Squeezy) lands and refunds become user-visible (a cancelled Pro user's mid-month rows need to behave correctly under the resolver swap), revisit whether monthly counters carry over period start metadata. Currently the user's first action after a tier change writes to a fresh row tagged with the new tier's caps — this is correct, but documenting the behaviour is worth a runbook entry. + +## Related + +- [ADR-020](ADR-020-tier-resolution-via-single-shim-function.md): the resolver that supplies the `tier` argument to every `check_and_increment` call. +- [ADR-018](ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md): the orchestrator-level retry layers that decide which failures get the refund treatment (per-agent fallback fires before refund; refund only fires when the whole workflow exits with an error). diff --git a/docs/adr/ADR-022-tier-aware-model-selection-via-constructor-injection.md b/docs/adr/ADR-022-tier-aware-model-selection-via-constructor-injection.md new file mode 100644 index 0000000..ab0858e --- /dev/null +++ b/docs/adr/ADR-022-tier-aware-model-selection-via-constructor-injection.md @@ -0,0 +1,111 @@ +# ADR-022: Tier-Aware Model Selection via Constructor Injection + +- Status: Accepted +- Date: 2026-05-15 + +## Context + +Step 6 of the Day 42 tier-enforcement series (commit `68be1d5`) introduced premium model routing: when a Pro / Business user opts into a premium application by setting `premium=True` on `/workspace/analyze`, the three "high-trust" supervised-pipeline agents (`review`, `resume_generation`, `cover_letter`) should run on `gpt-5.5` instead of the standard `gpt-5.4`. The fourth agent (`tailoring`) stays on `gpt-5.4-mini` regardless of plan — the COGS analysis pinned it there because tailoring carries the heaviest grounded payload and an upgrade would push the per-application cost past the Pro plan's revenue. + +The existing model selection lived in `src/config.py::get_openai_model_for_task(task_name)`, which reads `OPENAI_MODEL_ROUTING[task]` from env. This works fine for tier-independent routing, but the premium decision depends on three runtime facts that aren't visible to `src/config.py`: + +1. The user's tier (`backend.tiers.resolve_user_tier(app_user)`). +2. The premium flag on the current `/workspace/analyze` request. +3. Whether the current agent task is on the upgrade list. + +We needed somewhere to combine those three signals into a per-task model override, AND we needed every code path that ends up calling `responses.create(...)` for one of those agents — the primary path, the output-budget retry path (`_retry_with_higher_output_budget`), the app-level retry path (`_create_response_with_app_retry`), and the per-agent retry in the orchestrator (see ADR-018) — to honour the same override consistently. + +## Decision + +Compute the override once per workflow construction, pass it to the agents at instantiation, and let each agent inject the override into its `run_json_prompt(...)` calls. + +### `select_workflow_model` and `build_workflow_model_overrides` + +`backend/model_routing.py` exposes two helpers: + +```python +def select_workflow_model(*, task: str, tier: Tier, premium: bool) -> Optional[str]: + if not premium: return None + if tier not in {"pro", "business"}: return None + if task not in {"review", "resume_generation", "cover_letter"}: return None + return OPENAI_MODEL_ROUTING.get("premium_high_trust") # default "gpt-5.5" + +def build_workflow_model_overrides(*, tier: Tier, premium: bool) -> dict[str, Optional[str]]: + return {task: select_workflow_model(task=task, tier=tier, premium=premium) + for task in ("tailoring", "review", "resume_generation", "cover_letter")} +``` + +`None` means "no override, use the standard `OPENAI_MODEL_ROUTING[task]` lookup". The premium model name is itself env-configurable (`OPENAI_MODEL_PREMIUM` → routed under the `"premium_high_trust"` key in `OPENAI_MODEL_ROUTING`) so we can rotate models without code changes. + +### Constructor-time injection + +The orchestrator is constructed inside `WorkspaceService.run_workflow(...)` with the override map: + +```python +model_overrides = build_workflow_model_overrides(tier=tier, premium=premium) +orchestrator = ApplicationOrchestrator( + openai_service=openai_service, + model_overrides=model_overrides, + ..., +) +``` + +Each agent receives its task's override at construction: + +```python +review_agent = ReviewAgent(openai_service, model_override=model_overrides["review"]) +``` + +Inside `ReviewAgent.run(...)`, the override flows through `self._openai.run_json_prompt(..., model=self._model_override or None)`. The agent itself doesn't know whether the override came from a premium opt-in or a test fixture — it just forwards whatever was injected at construction. + +### Why constructor-time, not per-call + +The orchestrator's retry layers (ADR-018) re-issue the SAME agent's `.run(...)` after a transient failure. If the model override were a per-call parameter, every retry path — including the per-agent retry inside `run_agent_step`, the SDK's own retries, and the app-level retry — would need to know about it and pass it through. Each retry path is a separate function and is allowed to compose with the others; threading a model name through all of them is a maintenance liability. + +With constructor-time injection, the agent's `self._model_override` is set once and lives for the agent's lifetime. Every retry — at every layer — reads the same `self._model_override`. There's exactly one place to set it (orchestrator construction) and one place to read it (inside each agent's `run` method). + +## Alternatives Considered + +### 1. Per-call `model_override` parameter on each agent's `.run(...)` +Rejected. Every retry path needs to know about the override and forward it. The per-agent retry helper in `run_agent_step` (ADR-018) constructs the lambda once and re-invokes it — adding a model parameter to the lambda's closure works, but the same lambda is also the deterministic-fallback path (`AgentClass(None).run(...)`), where the model override is meaningless. The deterministic-fallback constructor would need a no-op override field just for symmetry. Worse, the orchestrator would need to thread the override into every `run_agent_step` call site, which means knowing per-call which task name to look up. Constructor injection keeps the orchestrator's call sites clean. + +### 2. Read tier from a thread-local request context inside the agent +Rejected. Pull-based context lookup in agents creates an implicit dependency on the request scope — agents stop being unit-testable in isolation, and the dependency is invisible at the constructor's call site. Constructor injection makes the dependency explicit and the agent independently testable with a `model_override="gpt-5.5"` argument. + +### 3. Resolve the model inside `OpenAIService._create_response_with_app_retry` +Rejected. The OpenAI service layer is below the agent layer; it doesn't know which task is firing. Moving tier-awareness into the OpenAI layer would either require passing the task name through to every `responses.create` call (which is what we're trying to avoid), or having the service introspect an opaque blob of context. The agent is the right boundary because it already knows what task it is. + +### 4. Resolve at orchestrator entry but route through a custom `OpenAIService` subclass +Considered. Build a `PremiumOpenAIService` that wraps `OpenAIService` and rewrites the model in `run_json_prompt`. Keeps the agents oblivious. Rejected because the wrapper would have to introspect the `task` kwarg in each call — which is fine for `run_json_prompt` (it explicitly takes `task=...`) but the streaming path (`run_text_stream`) and the parser paths take different signatures. The same wrapper class has to handle four call shapes, which is harder to read than each agent forwarding its own `self._model_override`. The constructor-injection pattern is what HelpmateAI's premium routing uses; copying the shape keeps mental load low. + +### 5. Make `tailoring` premium-eligible too +Rejected on COGS. The tailoring agent carries the largest grounded payload (full resume + JD + fit analysis + first-pass draft) and runs on every workflow, premium or not. Routing tailoring to `gpt-5.5` would either eat the premium revenue margin or force a price hike on the Pro plan that nobody asked for. The three review-grade agents (`review`, `resume_generation`, `cover_letter`) are where the perceived quality lift lives — they're the surfaces the user reads. + +## Consequences + +### Positive + +- Retry paths (SDK retries, app-level retry, per-agent retry, output-budget retry) are tier-correct by construction. No per-layer awareness of the override. +- Adding a new premium-eligible task is two lines: append it to `_PREMIUM_UPGRADE_TASKS` in `backend/model_routing.py` and to the `build_workflow_model_overrides` dict. The agent and its retry paths inherit the override for free. +- The premium model name is fully env-configurable. Rotating from `gpt-5.5` to a successor model is a single environment variable change. +- Tailoring's pinning is explicit (the absence from `_PREMIUM_UPGRADE_TASKS` is the source of truth) and defendable (the COGS reasoning is in the module docstring). +- The override function returns `None` defensively when the user's tier or premium flag is wrong. The gate at `/workspace/analyze` (ADR-021) is the authoritative source of *what gets charged*; this router decides *what gets served*. A regression in the gate can't silently issue premium credits without the upgraded model — and vice versa. + +### Negative + +- Two places to keep in sync if a new task wants premium routing: `_PREMIUM_UPGRADE_TASKS` and `build_workflow_model_overrides`. Mitigated by a unit test in `tests/backend/test_tier_aware_workflow_model.py` that pins the exact override dict shape; adding a task and forgetting one of the two locations fails CI. +- The orchestrator constructor now takes one more argument (`model_overrides`). Default to an empty dict so the deterministic path and existing test fixtures don't need to construct it; the dict's `.get(task)` returns `None` and the standard model lookup wins. +- A Free user who somehow bypasses the gate and reaches model selection with `premium=True` would still be served `None` (standard model) because `_PREMIUM_ELIGIBLE_TIERS` is the second check. Defensive layering, not a guarantee — the gate is the source of truth. + +## Follow-Up + +- Once Day 43 (Lemon Squeezy) lands and the resolver returns real tiers, watch the `OPENAI_MODEL_PREMIUM` cost per premium application. The COGS analysis used a 1.5× multiplier estimate vs `gpt-5.4`; live numbers may differ. +- Consider extending the same pattern to `assistant_turns` (a "premium assistant" tier?) — but only if there's a real product reason. The current decision is to keep the assistant on the standard mini model for everyone. +- Document the premium-eligible task list in the pricing page so users know which surfaces upgrade and which don't. + +## Related + +- [ADR-020](ADR-020-tier-resolution-via-single-shim-function.md): the resolver whose output feeds this router. +- [ADR-021](ADR-021-atomic-quota-with-refund-on-failure.md): the gate that authorizes the premium opt-in; this router serves what the gate authorized. +- [ADR-018](ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md): the retry layers that inherit the model override transparently because it lives on the agent instance. +- [ADR-010](ADR-010-single-pass-review-corrections-and-task-tuned-model-budgets.md): the per-task model routing baseline this premium override sits on top of. diff --git a/docs/adr/ADR-023-lemon-squeezy-merchant-of-record-for-v1.md b/docs/adr/ADR-023-lemon-squeezy-merchant-of-record-for-v1.md new file mode 100644 index 0000000..9d2f2d9 --- /dev/null +++ b/docs/adr/ADR-023-lemon-squeezy-merchant-of-record-for-v1.md @@ -0,0 +1,95 @@ +# ADR-023: Lemon Squeezy as Merchant of Record for v1 + +- Status: Accepted +- Date: 2026-05-15 + +## Context + +The Day 42 tier-enforcement series shipped the full per-tier gating matrix but with every user resolving to `"free"` because there was no payment processor wired in. Day 43 closes that loop. The choice is not "should we accept payments?" — the enforcement code assumes paid tiers exist — but "which processor, and what does that imply for the architecture?". + +Three operational constraints shaped the choice: + +1. **The developer is a solo Indian resident, not a registered company.** Stripe and Razorpay both require an incorporated business entity (private limited / LLP / sole proprietorship with GST registration) and a business bank account before they will release payouts. Stripe India in particular gates on a business KYC review that has historically taken 4-8 weeks for new accounts, even for non-resident company structures. +2. **The product needs to handle US, EU, UK, and Indian payments from day one.** The job-application audience skews international; a payment processor that requires per-region setup (e.g. Razorpay for India + Stripe for everywhere else, with separate tax handling on each) doubles the surface area and forces the application to know which processor to route a given user to. +3. **VAT / GST / sales tax handling needs to be solved, not deferred.** EU customers expect a VAT invoice. US customers in tax-collecting states expect sales tax. India expects GST on B2B sales. We don't want to build tax-calculation logic ourselves and we don't want to register for VAT in every EU country individually. + +A Merchant of Record (MoR) processor is the one that solves all three at once: the MoR is the legal seller of record, they handle the tax collection and remittance in every jurisdiction they cover, and they pay out to the developer in INR via wire after they've collected and remitted everything else. + +## Decision + +Use **Lemon Squeezy** as the v1 payment processor, integrated as a Merchant of Record. The `aijobagent_subscriptions.processor` column is a free-form text field so a future Stripe + Razorpay direct integration can land alongside LS without a schema migration. + +### What Lemon Squeezy provides + +- US-incorporated MoR (Lemon Squeezy LLC, a Stripe-owned subsidiary as of 2024). +- Handles VAT collection + remittance in every EU country, UK VAT, US sales tax in collecting states, and a single 1099 / Form 26AS at year-end for the seller. +- Hosted checkout pages (no PCI scope on our backend), customer portal for self-serve subscription management, and HMAC-signed webhooks for state changes. +- Indian-resident payouts in INR via local bank transfer after the LS payout schedule (weekly to monthly depending on volume). +- Fee structure: 5% + 50¢ per transaction. Higher than Stripe's 2.9% + 30¢, but the spread covers everything LS does on our behalf — and 2.9% + 30¢ wasn't available to us anyway without a business entity. + +### Integration shape + +The Day 43 LS scaffold (commits `1b8cf95`..`a236c81`) lands four pieces: + +1. **`aijobagent_subscriptions` table** holds the LS-authoritative subscription state. One row per (active or past) subscription with columns `user_id`, `processor`, `processor_subscription_id`, `processor_customer_id`, `tier`, `status`, `current_period_end`. Partial unique index on `(user_id) WHERE status = 'active'` enforces at most one active sub per user. +2. **`backend/subscriptions.py`** is the store wrapper. It exposes `find_active(user_id) -> Subscription | None` (consulted by the post-Day-43 `resolve_user_tier` body) and `upsert_from_webhook(event)` (called by the webhook router). +3. **`POST /api/webhooks/lemonsqueezy`** verifies the HMAC-SHA256 signature using `hmac.compare_digest` against `LEMONSQUEEZY_WEBHOOK_SECRET` and dispatches by `meta.event_name`: `subscription_created`, `subscription_updated`, `subscription_payment_success` (refreshes `current_period_end`), `subscription_cancelled` (status → `cancelled`, the user keeps access until `current_period_end`), `subscription_expired` (status → `expired`). +4. **Frontend Upgrade CTA + customer portal link** opens the LS hosted checkout for the appropriate variant ID when `NEXT_PUBLIC_LEMONSQUEEZY_*` env vars are present; falls back to a "Coming soon" disabled-button-plus-tooltip when they're not, so the production frontend keeps shipping without waiting on LS dashboard config. + +### Architectural neutrality + +The `processor` column is text. When (not if) we move to Stripe + Razorpay direct: + +- A user with both an active LS sub and an active Stripe sub has two rows; `find_active` picks the highest tier across them. +- The LS webhook continues to flow as long as legacy LS subscriptions exist. Migration is gradual — new signups use the new processor, existing LS subs ride out their current period. +- No table migration needed at processor #2; the column was always polymorphic. + +Variant IDs (LS's primary key for "this thing is purchasable") are kept in env vars (`LEMONSQUEEZY_VARIANT_PRO`, `LEMONSQUEEZY_VARIANT_BUSINESS`) rather than the database, so rotating variants for an A/B test is a deploy, not a migration. + +## Alternatives Considered + +### 1. Stripe direct (after company incorporation) +Rejected for v1. Incorporating a private limited company in India costs ~₹15-25k, takes 2-4 weeks, requires CA-attested books from month one, and locks the developer into mandatory annual ROC filings + corporate income tax filings even on zero revenue. We're not at the revenue scale where the per-transaction savings justify those overheads. Revisit once monthly recurring revenue justifies the corporate setup — the LS-to-Stripe migration path is documented above and is non-disruptive. + +### 2. Razorpay direct +Rejected for v1. Razorpay is the best-in-class processor for Indian buyers but doesn't help with the US/EU/UK audience we're targeting. It also doesn't act as MoR — we'd own the VAT collection problem ourselves for every EU customer. A future "Razorpay for Indian buyers + Stripe for everyone else" architecture is plausible after incorporation, but it's a v2+ decision. + +### 3. Paddle +Considered seriously. Paddle is the other major MoR option and is generally cheaper than LS for higher transaction volumes (5% capped vs 5% + 50¢). Two factors tipped the choice to LS: (a) LS's webhook contract is simpler and better-documented than Paddle's V2 API (Paddle has gone through three API versions; the migration cost of being on V2 vs V3 is non-trivial), and (b) LS's hosted checkout is a first-class native UX while Paddle's is more iframe-heavy. If LS ever materially changes terms, Paddle is the documented fallback (the `processor` column accepts a new value). + +### 4. Gumroad +Rejected. Gumroad is MoR and has lower friction than LS for one-off digital products, but their subscription support is thin — no native customer portal, weaker webhook contract, and the pricing model favours single-purchase products over recurring revenue. + +### 5. Self-managed payments via PayPal +Rejected. PayPal isn't a true MoR; we'd still own VAT/tax handling. The PayPal-only payment surface also has known UX weakness for the US business audience (subscription chargebacks via PayPal historically resolve in the buyer's favour by default). + +## Consequences + +### Positive + +- Payments are live without incorporating a company. The developer remains a solo individual until revenue justifies the business setup, and LS handles every tax obligation in the meantime. +- One processor covers the global audience. No per-region routing logic in the application. +- Hosted checkout means no PCI scope on our backend — the LS-hosted card page is the only surface that sees card data. +- Customer portal is free — no need to build cancel / change-payment-method / view-invoices UI ourselves. +- The architectural neutrality (`processor` column + variant IDs in env vs DB) means migrating to Stripe + Razorpay later is a code change, not a schema change. +- Webhook contract is HMAC-signed and idempotent; the store's `upsert_from_webhook(event)` is safe to call repeatedly on retries. + +### Negative + +- 5% + 50¢ is higher than Stripe direct (2.9% + 30¢). On a $19/mo Pro plan that's $1.45 vs $0.85 — a $0.60 spread per subscription per month. At 1000 active subs that's $7,200/year in differential fees, which is the threshold at which incorporating becomes economically worth it. +- LS payouts are weekly-to-monthly, not real-time. Cash-flow planning lives on the LS dashboard, not in our books. +- LS's variant IDs are opaque integers — every environment (dev, staging, prod) has its own set, and the frontend + backend each need their own copy via separate `NEXT_PUBLIC_*` / non-prefixed env vars. Mitigated by documenting the env-var matrix in `docs/lemon-squeezy.md`. +- If LS changes terms (fee structure, payout cadence, supported regions), the migration cost is real even with the architectural neutrality — we'd still need to build the second processor's integration. The neutrality just means the *application* code doesn't fight us; the *operational* migration is its own project. +- LS handles disputes and chargebacks on our behalf, but a chargeback still reaches us as a negative balance against the next payout. We have no direct control over LS's dispute-handling policy. + +## Follow-Up + +- Once the LS variant IDs are configured and the webhook is verified end-to-end against the LS sandbox, flip `feat/lemonsqueezy-integration` from "scaffold ready" to "live". Removing the "Coming soon" frontend fallback is a one-line change. +- Operational runbook for the LS dashboard (variant rotation, webhook secret rotation, refund flow, dispute response) — separate from this ADR, owned by the operator. +- Once 100 active subscriptions are reached, revisit the Stripe-direct cost analysis with real numbers. The migration path is documented; the trigger for executing it is revenue. + +## Related + +- [ADR-020](ADR-020-tier-resolution-via-single-shim-function.md): the resolver whose post-Day-43 body consults the `aijobagent_subscriptions` table this processor populates. +- [ADR-021](ADR-021-atomic-quota-with-refund-on-failure.md): the enforcement layer that's now fully load-bearing once paid tiers exist. +- [ADR-022](ADR-022-tier-aware-model-selection-via-constructor-injection.md): the premium model routing whose tiered behaviour becomes user-visible once payments go live. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..3ac3a44 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,30 @@ +# ADR Index + +This directory tracks the architectural decisions that shape the AI Job Application Agent. + +These ADRs are historical records. Several earlier entries describe the old Streamlit-first product phase. The accepted set below reflects the current direction of the shipped Next.js + FastAPI workspace. + +## Accepted + +- [ADR-001: Lightweight document parsing for MVP](ADR-001-lightweight-document-parsing.md) +- [ADR-002: Demo assets for reproducible product flows](ADR-002-demo-assets-for-reproducible-product-flows.md) +- [ADR-007: Remove LinkedIn import from active product scope](ADR-007-remove-linkedin-import-from-active-product-scope.md) +- [ADR-009: Google sign-in via Supabase for persistent identity](ADR-009-google-sign-in-via-supabase-for-persistent-identity.md) +- [ADR-010: Single-pass review corrections and task-tuned model budgets](ADR-010-single-pass-review-corrections-and-task-tuned-model-budgets.md) +- [ADR-011: Unified grounded assistant surface](ADR-011-unified-grounded-assistant-surface.md) +- [ADR-012: Next.js workspace and FastAPI runtime baseline](ADR-012-nextjs-workspace-and-fastapi-runtime-baseline.md) +- [ADR-013: Cached jobs cache layer with scheduled refresh](ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) +- [ADR-014: Postgres RPC for ranked job search](ADR-014-postgres-rpc-for-ranked-search.md) +- [ADR-015: DOCX-first artifact export with theme palette](ADR-015-docx-first-artifact-export-with-theme-palette.md) +- [ADR-016: Conversational LLM resume builder](ADR-016-conversational-llm-resume-builder.md) +- [ADR-017: Workspace assistant — ungated and state-aware context](ADR-017-workspace-assistant-state-aware-context.md) +- [ADR-018: Three-layer LLM retry and per-agent fallback isolation](ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md) +- [ADR-019: Independent step navigation in the workspace](ADR-019-independent-step-navigation.md) + +## Superseded + +- [ADR-003: Streamlit session state for navigation and persistence](ADR-003-streamlit-session-state-for-navigation-and-persistence.md) — superseded by ADR-012 +- [ADR-004: LinkedIn data export ingestion instead of direct API access](ADR-004-linkedin-data-export-ingestion-instead-of-direct-api-access.md) — superseded by ADR-007 +- [ADR-005: Streamlit-first, backend-ready delivery strategy](ADR-005-streamlit-first-backend-ready-delivery.md) — superseded by ADR-012 +- [ADR-006: Playwright-first PDF export with ReportLab fallback](ADR-006-playwright-first-pdf-export.md) — superseded by ADR-015 +- [ADR-008: Two-mode grounded assistant panel](ADR-008-two-mode-grounded-assistant-panel.md) — superseded by ADR-011 \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..422b267 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,256 @@ +# Architecture Overview + +This document describes the current runtime architecture of the AI Job Application Agent. + +## System Goal + +The app helps a candidate: + +- sign in with Google +- upload and parse a resume +- search technical jobs or import a supported job link +- upload or paste a job description +- review a structured JD summary +- run a grounded agentic workflow +- review a tailored resume and cover letter +- ask grounded follow-up questions in the workspace assistant +- export DOCX or PDF versions of the generated documents (the earlier Markdown export path was removed in 2026-05; see [ADR-015](adr/ADR-015-docx-first-artifact-export-with-theme-palette.md)) + +## Runtime Shape + +The product now runs as a split web application: + +- `frontend/` is the Next.js workspace deployed on Vercel +- `backend/` is the FastAPI API deployed on the VPS +- `src/` contains the shared Python workflow, builders, orchestration, auth helpers, and persistence logic +- `backend/vps/` contains the Docker Compose + Caddy deployment bundle for the backend stack + +This is no longer a Streamlit runtime. The old Streamlit shell and related deployment files were removed from the active codebase. + +## High-Level Flow + +1. The user opens the Next.js workspace. +2. The user signs in with Google through Supabase-backed auth endpoints. +3. The user uploads a resume. +4. The backend parses the resume and builds a normalized candidate profile. +5. The user can search configured Greenhouse, Lever, Ashby, and Workday sources via the Supabase-cached job index (or paste a supported job URL, or continue manually with JD text). +6. The app builds a structured JD summary for review. +7. The user explicitly triggers the agentic workflow. +8. The orchestrator runs `tailoring`, `review`, `resume_generation`, and `cover_letter`. The earlier `fit` and `strategy` stages were removed from the live workflow; the deterministic fit-scoring service in `src/services/fit_service.py` is still available as a building block for `tailoring` but is no longer a visible workflow stage. +9. Builders assemble the tailored resume and cover letter. +10. The workspace assistant answers grounded questions from the current workspace state. +11. Export helpers produce DOCX and PDF files for the current document; both formats share the same theme palette (`classic_ats`, `professional_neutral`). +12. For authenticated users, the latest workspace snapshot and saved jobs are persisted in Supabase. + +## Main Modules + +### `frontend/` + +Owns the user-facing workspace: + +- account state and sign-in flow (signed-out users hitting `/workspace` get redirected to the landing page; cross-origin host strip mirrors the existing app-subdomain middleware) +- resume intake (Upload mode + Build with assistant conversational chat; see [ADR-016](adr/ADR-016-conversational-llm-resume-builder.md)) +- job search and saved jobs +- JD review +- workflow progress UI +- document preview and export actions +- assistant chat — not gated; answers product-help questions from the first visit and grounded package questions once an analysis has run; see [ADR-017](adr/ADR-017-workspace-assistant-state-aware-context.md) + +Step rail navigation: Resume / Job Search / Job Detail are independently accessible — a user can paste a JD without a resume, or browse listings without uploading anything. Only Analysis is gated (it requires both a parsed resume and a parsed JD); the rail-level lock is a hint, and the AnalysisRunner page surfaces what's missing when the user lands there early. See [ADR-019](adr/ADR-019-independent-step-navigation.md). + +### `backend/` + +Owns the FastAPI API surface: + +- `backend/app.py` bootstraps the API +- `backend/routers/health.py` exposes deployment smoke signals +- `backend/routers/jobs.py` exposes the cache-backed search, the `?live=true` escape-hatch fan-out, direct job-resolution endpoints, and the bearer-protected `POST /admin/refresh-cache` endpoint that drives the cached-jobs refresh worker +- `backend/routers/auth.py` owns auth/session endpoints +- `backend/routers/workspace.py` owns resume, JD, workflow, assistant (both non-streaming and SSE), persistence, preview, export, resume-builder chat, and resume-builder export endpoints +- `backend/services/job_cache_service.py` runs the per-source refresh + smart-cleanup worker invoked by the admin endpoint + +### `src/services/` + +Owns deterministic business logic: + +- candidate-profile construction from resume input (`profile_service.py`) +- JD normalization (`job_service.py`) plus a `jd_summary_service.py` view layer +- LLM-hybrid resume + JD parsers (`resume_llm_parser_service.py`, `jd_llm_parser_service.py`) — pure-LLM source of truth with a deterministic fallback +- fit scoring (`fit_service.py`) — still used by tailoring, no longer a visible workflow stage +- first-pass tailoring guidance (`tailoring_service.py`) + +These services are transport-agnostic and do not depend on Next.js or FastAPI. + +### `src/agents/` + +Owns the supervised orchestration layer. + +The active orchestrator path runs: + +- tailoring +- review +- resume generation +- cover letter + +The earlier `fit` and `strategy` stages are no longer part of the live workflow. The `TailoringAgent` consumes the structured `FitAnalysis` produced by `src/services/fit_service.py` directly — no FitAgent narration step. Each agent has a Tier-2/Tier-3 quality runner under `tests/quality/` that scores it on fixture (resume, JD) pairs. + +**Per-agent retry + fallback isolation.** Each agent step inside the orchestrator gets its own retry budget and its own fallback path. If an agent's LLM call raises `AgentExecutionError` (after the OpenAI service's own SDK + app-level retries exhaust), the orchestrator retries the agent's full `.run(...)` once with a 400 ms delay. If the retry also fails, only THAT agent's deterministic fallback runs — downstream agents continue trying the LLM path. A single bad packet during the Forge agent no longer cascades to "downgrade the whole pipeline to deterministic." The whole-pipeline deterministic fallback remains as a safety net for the unusual case where a per-agent deterministic path itself errors out. If every agent ended up falling back per-agent (zero LLM successes), `result.mode` is honestly downgraded to `deterministic_fallback`. See [ADR-018](adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md). + +### `src/prompts.py` + +Owns grounded prompt builders for the specialist agents and assistant. + +### `src/openai_service.py` + +Owns the thin OpenAI wrapper used by the workflow and assistant layers. + +Responsibilities include: + +- task-aware model routing +- Responses API calls (JSON-contract path via `run_json_prompt`, streaming prose path via `run_text_stream`) +- GPT-5 reasoning-effort routing +- usage accounting metadata +- optional persisted usage-event callbacks +- daily-quota preflight checks +- output-budget retry handling (when responses are truncated due to insufficient `max_output_tokens`) +- application-level retry on top of the OpenAI Python SDK's own retries (`max_retries=2`) — adds one extra attempt on the narrow allow-list `APIConnectionError` / `APITimeoutError` / `InternalServerError`. Every `responses.create` in the codebase routes through `_create_response_with_app_retry`, so the resume parser, JD parser, JD summary, all four supervised-workflow agents, and the assistant chat all inherit the retry layer for free. See [ADR-018](adr/ADR-018-three-layer-llm-retry-and-per-agent-fallback-isolation.md). + +### `src/assistant_service.py` + +Owns the single in-app assistant behavior. The chat is **not gated** on having run an analysis — it answers product-help questions ("how do I use this?", "what's step 03 for?") from the very first visit and grounded package questions ("summarize my fit") once an analysis has run. See [ADR-017](adr/ADR-017-workspace-assistant-state-aware-context.md). + +Responsibilities include: + +- routing between product-help questions and grounded package questions +- compact workspace-context assembly, including a `workspace_state` projection (`current_step`, `has_resume`, `resume_summary`, `has_jd`, `jd_summary`, `has_analysis`, `saved_jobs_count`, `last_search_query`) sent on every query so the LLM can answer state-aware questions before any analysis exists +- deterministic fallback behavior when assisted execution is unavailable + +### Builders and Exporters + +- `src/resume_builder.py`: deterministic tailored-resume assembly +- `src/cover_letter_builder.py`: deterministic grounded cover-letter assembly +- `src/exporters.py`: DOCX/PDF export helpers (`export_docx_bytes`, `export_pdf_bytes`) plus HTML preview generation, sharing a theme palette across formats; see [ADR-015](adr/ADR-015-docx-first-artifact-export-with-theme-palette.md) +- `src/job_sources/`: per-provider adapter implementations (Greenhouse, Lever, Ashby, Workday) feeding the cached-jobs refresh worker + +The user-facing workspace is now centered on two visible outputs: + +- tailored resume +- cover letter + +Both ship in two themes (`classic_ats`, `professional_neutral`) and both formats (DOCX, PDF). The earlier Markdown export path was removed in 2026-05 alongside the DOCX rollout. The earlier internal report builder was removed when the FitAgent + bundle endpoint were retired. + +### Auth and Persistence Modules + +- `src/auth_service.py`: Supabase Auth wrapper for Google OAuth +- `src/user_store.py`: syncs lightweight `app_users` records +- `src/usage_store.py`: persists authenticated assisted usage events +- `src/quota_service.py`: computes daily quota state from persisted usage +- `src/saved_workspace_store.py`: persists and loads the latest reloadable workspace snapshot +- `src/saved_jobs_store.py`: persists and loads shortlisted jobs +- `src/cached_jobs_store.py`: service-role-backed access layer for the global `cached_jobs` index — bulk upsert, smart cleanup, ranked search via Postgres RPC; see [ADR-013](adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) and [ADR-014](adr/ADR-014-postgres-rpc-for-ranked-search.md) +- `src/resume_builder_store.py`: persists and loads conversational resume-builder draft sessions (`resume_builder_sessions` table) with the 7-day TTL + active-user refresh policy; see [ADR-016](adr/ADR-016-conversational-llm-resume-builder.md) + +### `src/config.py` + +Owns environment-backed configuration for: + +- model routing +- reasoning routing +- quota defaults +- auth and Supabase settings +- saved-workspace retention settings +- frontend/backend integration settings + +### `src/schemas.py` + +Owns shared typed models for: + +- resumes +- candidate profiles +- work experience +- education +- job descriptions +- fit analyses +- tailoring drafts +- tailored resume artifacts +- cover letter artifacts +- internal reports +- agent outputs +- orchestrated workflow results +- auth and persistence records + +## Persistence Model + +The runtime uses a split state model: + +- browser state for the current workspace session +- Supabase Postgres for authenticated persistence and the global cached-jobs index + +Per-user persistent state: + +- `app_users` +- `usage_events` +- `saved_workspaces` +- `saved_jobs` +- `resume_builder_sessions` + +Global (non-user-scoped) state: + +- `cached_jobs` — the indexed set of upstream postings refreshed every 4 hours (six times a day) by the backend's `refresh_cached_jobs` worker; see [ADR-013](adr/ADR-013-cached-jobs-cache-layer-with-scheduled-refresh.md) + +Each `saved_workspaces` row stores one latest snapshot per user, including enough data to restore the current resume/JD/workflow state. + +Each `saved_jobs` row stores one shortlisted posting per user and normalized job id, including: + +- source/provider identity +- title, company, location, and employment type +- source URL +- normalized summary and description text +- provider metadata +- saved and updated timestamps + +Each `resume_builder_sessions` row stores one in-progress conversational resume-builder draft per user with a 7-day TTL refreshed on every save. A `pg_cron` job (`cleanup-expired-resume-builder-sessions`) hard-deletes expired rows every 5 min and RLS hides expired rows from per-user queries; see [ADR-016](adr/ADR-016-conversational-llm-resume-builder.md). + +Each `cached_jobs` row holds one upstream posting keyed on `(source, job_id)`. The table has GENERATED STORED columns (`work_mode`, `employment_type_norm`) backing the dropdown filters and `removed_at` tombstones for upstream-closed jobs the user has bookmarked. A `pg_cron` + `pg_net` schedule (`cached_jobs_refresh_4h`) POSTs to `/admin/refresh-cache` every 4 hours, six times a day (see `docs/sql/job_cache_cron_setup.sql` for the template — production runs `0 */4 * * *`); ranked search reads from this table via the `search_cached_jobs_ranked` RPC, per [ADR-014](adr/ADR-014-postgres-rpc-for-ranked-search.md). + +## Testing Model + +The repo includes focused tests for: + +- resume parsing +- JD parsing (deterministic + LLM-hybrid) +- profile normalization +- job normalization +- tailoring guidance +- orchestrator behavior +- resume and cover-letter building +- DOCX + PDF export formatting +- auth and quota behavior +- saved-workspace persistence +- saved-job persistence +- cached-jobs store + RPC arg shape +- cached-jobs refresh worker (per-source isolation, cleanup gating, status reporting) +- per-provider job source adapters (Greenhouse, Lever, Ashby, Workday) +- conversational resume-builder turn handling + structuring pass +- backend workspace routes +- assistant SSE streaming endpoint +- OpenAI application-level retry contract (`tests/test_openai_app_retry.py`): retries on the narrow allow-list `APIConnectionError` / `APITimeoutError` / `InternalServerError`, does NOT retry on 4xx / auth / persistent rate-limit, returns success after retry, raises on double-failure +- per-agent orchestrator behavior (`tests/test_orchestrator.py`): per-agent retry recovers a flaky agent, per-agent fallback isolates a single failing agent (downstream agents still use LLM), `result.mode` reconciles to `deterministic_fallback` when no agent succeeded with LLM + +Tier-2 / Tier-3 quality runners under `tests/quality/` evaluate LLM-driven components (resume parser, JD parser, renderer fidelity, skill canonicalization, tailoring, review, resume generation, cover letter, resume builder, assistant, end-to-end orchestrator) on fixture sets with weighted scorecards and a `--include-llm` cost gate. + +## Current Constraints + +- Long AI-assisted runs still execute as one request/response cycle today; they are not yet background jobs. +- The product stores one latest saved workspace snapshot per user; it does not expose a multi-entry history browser. +- Large binary artifacts are regenerated on demand instead of being stored in object storage. +- The internal report builder still exists in Python, but the visible workspace now centers on resume and cover letter only. + +## Next Architecture Step + +The next meaningful expansion is product hardening on the current stack: + +- background execution for long-running workflow jobs +- tighter hosted reliability around retries and timeouts +- continued UI simplification around review and export +- broader hosted QA across Vercel, VPS, Supabase, and Cloudflare diff --git a/docs/images/classic_resume_render.jpg b/docs/images/classic_resume_render.jpg new file mode 100644 index 0000000..926d40d Binary files /dev/null and b/docs/images/classic_resume_render.jpg differ diff --git a/docs/images/cover_letter_render.jpg b/docs/images/cover_letter_render.jpg new file mode 100644 index 0000000..cb38261 Binary files /dev/null and b/docs/images/cover_letter_render.jpg differ diff --git a/docs/images/job-agent-architecture.svg b/docs/images/job-agent-architecture.svg new file mode 100644 index 0000000..8db96ca --- /dev/null +++ b/docs/images/job-agent-architecture.svg @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + AI Job Application Agent + Cached job search across four ATS providers, grounded resume + JD parsing, and a five-stage supervised pipeline that produces a tailored resume + cover letter. + + + INPUTS + + + + + + RESUME + Upload or chat + PDF · DOCX · TXT · or LLM Q&A + + + + + + CACHED JOB INDEX + ~12,000 open roles + Greenhouse · Lever + Ashby · Workday · 4-hour refresh + + + + + + JD + Selected, URL, or pasted + LLM parser · skill chips · match score + + + + 5-STAGE SUPERVISED PIPELINE + + + + + + 1 + DETERMINISTIC + Matchmaker + match score + matched / missing skills + + + + + + + + 2 + Forge + tailoring agent + rewrites bullets to JD + + + + + + + + 3 + GROUNDED REVIEW + Gatekeeper + flags unsupported claims + returns corrected_tailoring + + + + + + + + 4 + Resume Gen + final assembly + structured layout + + + + + + + + 5 + Cover Letter + role-specific + narrative + + + + + + + + + + + + if review flags unsupported claims → revise + + + only if approved + + + OUTPUTS + + + + + + + + + TAILORED RESUME + DOCX · PDF + classic_ats · professional_neutral + + + + + COVER LETTER + DOCX · PDF + classic_ats · professional_neutral + + + + + + GROUNDED WORKSPACE ASSISTANT + Streams answers from current workspace state + current_step · resume + JD summaries · saved jobs · last query + + + + + + FOUNDATION + + + + + 3-layer LLM retry · per-agent fallback isolation · deterministic floor + SDK retries × 2 + app-level retry + per-agent retry + + + + + Supabase auth · cached_jobs · saved workspaces · quota tracking + FastAPI on VPS (Caddy) · Next.js on Vercel · Postgres RPC + + diff --git a/docs/images/job-copilot-logo.png b/docs/images/job-copilot-logo.png new file mode 100644 index 0000000..421b770 Binary files /dev/null and b/docs/images/job-copilot-logo.png differ diff --git a/docs/images/landing_jd.png b/docs/images/landing_jd.png new file mode 100644 index 0000000..ddcc3f3 Binary files /dev/null and b/docs/images/landing_jd.png differ diff --git a/docs/images/landing_jobsearch.png b/docs/images/landing_jobsearch.png new file mode 100644 index 0000000..a6475c5 Binary files /dev/null and b/docs/images/landing_jobsearch.png differ diff --git a/docs/images/landing_resumebuilder.png b/docs/images/landing_resumebuilder.png new file mode 100644 index 0000000..466724b Binary files /dev/null and b/docs/images/landing_resumebuilder.png differ diff --git a/docs/images/modern_resume_render.jpg b/docs/images/modern_resume_render.jpg new file mode 100644 index 0000000..b9f30ff Binary files /dev/null and b/docs/images/modern_resume_render.jpg differ diff --git a/docs/lemon-squeezy.md b/docs/lemon-squeezy.md new file mode 100644 index 0000000..19623a8 --- /dev/null +++ b/docs/lemon-squeezy.md @@ -0,0 +1,145 @@ +# Lemon Squeezy subscription integration + +Scope: how the AI Job Agent ties its tier-enforcement system to Lemon +Squeezy (LS) for paid Pro / Business plans. The tier-gating itself is +already shipped (see `backend/tiers.py` + `backend/quota.py`); this +doc covers how subscription state lands in the `subscriptions` table +and how the frontend invokes hosted checkout / customer portal. + +## Architecture + +``` + LS hosted checkout (sandbox or live) + | + user --> pricing CTA -+--> ?checkout[custom][user_id]= + | + v + LS webhook delivery + | + v + POST /api/webhooks/lemonsqueezy (HMAC-SHA256 verified) + | + v + backend/webhooks/lemonsqueezy.py + parse → idempotency check → event-to-status mapping + | + v + backend/subscriptions.py + upsert subscriptions row + invalidate LRU cache + | + v + backend/tiers.resolve_user_tier() + reads through 60s LRU keyed by (user_id, UTC minute) + | + v + quota gates everywhere +``` + +## Files + +| File | Purpose | +| --- | --- | +| `docs/sql/supabase-subscriptions.sql` | `subscriptions` + `subscription_webhook_log` tables with RLS. Apply in the Supabase SQL editor. | +| `backend/subscriptions.py` | Read/write helper for `subscriptions`. LRU cache keyed by `(user_id, UTC minute)`; webhook upserts also invalidate. | +| `backend/webhooks/lemonsqueezy.py` | HMAC verification + payload parsing + event-to-status mapping + idempotency. Pure logic; no FastAPI. | +| `backend/routers/billing.py` | `POST /api/webhooks/lemonsqueezy` + `POST /api/billing/portal`. | +| `backend/tiers.py` | `resolve_user_tier(app_user)` — now consults `get_active_subscription`. Unchanged contract for all gate callers. | +| `frontend/src/lib/api.ts` | `getCheckoutUrl(tier, userId)`, `isLemonSqueezyEnabled()`, `getCustomerPortalUrl()`. | +| `frontend/src/components/landing-page.tsx` | Pricing CTAs route to LS hosted checkout. Falls back to "Coming soon" / mailto when LS isn't configured. | +| `frontend/src/components/workspace/WorkspaceShell.tsx` | Manage subscription button (paid tiers only) + post-checkout quota refresh. | + +## Event → state mapping + +| LS event | `status` written | `cancel_at_period_end` | Tier impact | +| --- | --- | --- | --- | +| `subscription_created` | `active` | false | Grants tier from variant_id. | +| `subscription_updated` | `active` | from payload | Refreshes row from payload. | +| `subscription_cancelled` | `cancelled` | true | Tier kept until `current_period_end`. | +| `subscription_resumed` | `active` | false | Cancellation reverted. | +| `subscription_expired` | `expired` | — | Terminal downgrade to Free. | +| `subscription_paused` | `paused` | — | Soft downgrade to Free. | +| `subscription_unpaused` | `active` | — | Tier restored. | +| `subscription_payment_success` | `active` | — | `current_period_end` refreshed. | +| `subscription_payment_failed` | `past_due` | — | Tier kept during dunning. | +| `subscription_payment_recovered` | `active` | — | Dunning cleared. | + +Anything else (order events, unknown variant, missing `user_id`) is +logged + skipped with a 200 response so LS doesn't retry. + +## Tier resolution semantics + +`resolve_user_tier(app_user)` returns: + +* `"free"` — no user, no subscription row, status in {`expired`, `paused`}, period elapsed, or unknown tier. +* `sub.tier` (one of `"pro"` / `"business"`) when: + * `status ∈ {active, cancelled, past_due}` AND + * `current_period_end > now()` + +The read is LRU-cached for up to ~60 seconds. The webhook handler +invalidates the cache on every upsert so a fresh checkout return +sees the new tier on the next /workspace/quota call. + +## Idempotency + +LS retries on non-2xx and has at-least-once semantics. Two layers +protect against duplicate processing: + +1. `subscription_webhook_log` (PK = `event_id` derived from `meta.webhook_id`). A duplicate delivery short-circuits to `{"status": "duplicate"}` and returns 200. +2. The `subscriptions` upsert is keyed on `user_id` and idempotent by construction — even if the log lookup fails open, re-running the upsert produces the same row. + +## Setup (post-merge, before flipping to live LS) + +1. **Apply the SQL migration** in the Supabase SQL editor: + ```sql + \i docs/sql/supabase-subscriptions.sql + ``` +2. **Create LS sandbox store + products**. Two variants: Pro ($9/mo) and Business ($39/mo). Note the numeric `variant_id` from each variant's API resource. +3. **Set env vars on the VPS**: + * `AIJOBAGENT_LEMONSQUEEZY_API_KEY` — sandbox API key (Settings → API). + * `AIJOBAGENT_LEMONSQUEEZY_WEBHOOK_SECRET` — generated in step 4 below. + * `AIJOBAGENT_LEMONSQUEEZY_STORE_ID` — store_id (numeric) from the store settings. + * `AIJOBAGENT_LEMONSQUEEZY_PRODUCT_VARIANT_PRO` / `_BUSINESS` — variant_ids from step 2. +4. **Register the webhook** in the LS dashboard: + * URL: `https:///api/webhooks/lemonsqueezy`. + * Secret: generate a 32+ char random string and paste into both the dashboard and the `_WEBHOOK_SECRET` env var. + * Events to subscribe to: all `subscription_*` events. +5. **Set frontend env vars**: + * `NEXT_PUBLIC_LEMONSQUEEZY_STORE_ID` — store *subdomain* (e.g. `yourstore` for `yourstore.lemonsqueezy.com`). + * `NEXT_PUBLIC_LEMONSQUEEZY_PRODUCT_VARIANT_PRO` / `_BUSINESS` — same numeric variant_ids as the backend. +6. **Test in sandbox** — use the test card numbers from LS docs. A `subscription_created` webhook should arrive within ~5s; verify the `subscriptions` row via the Supabase table editor. +7. **Flip to live mode** — generate live API key + webhook secret, swap env vars, re-register the webhook in the live dashboard. Sandbox and live are entirely separate; nothing else changes in code. + +## Local development + +Without the env vars set, the backend webhook returns 503 (with a 5 +minute Retry-After) and the frontend pricing CTA renders "Coming +soon" for Pro / falls back to the existing `mailto:` for Business. +This keeps `feat/lemonsqueezy-integration` mergeable into `main` +without requiring LS to be live. + +To test the webhook locally: + +```bash +# Compute a signature for a test body +python -c " +import hmac, hashlib, json +secret = 'your-test-secret' +body = json.dumps({'meta': {...}, 'data': {...}}).encode() +print(hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()) +" +# POST with that signature +curl -X POST http://localhost:8000/api/webhooks/lemonsqueezy \ + -H "X-Signature: " \ + -H "Content-Type: application/json" \ + -d '' +``` + +The test suite in `tests/backend/test_lemonsqueezy_webhook.py` covers +each event type, signature failure, missing config, and idempotency. + +## References + +* Hosted checkouts: +* Webhook signing: +* Subscription API: +* Customer object (portal URL field): diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..3c44f5d --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,115 @@ +# Operations runbook + +Day-2 operational tasks that don't fit cleanly into `architecture.md` +or an ADR. Cron schedules, alert thresholds, manual runbook entries. + +## Nightly quality eval (`backend.nightly_eval`) + +The nightly eval is the production-safety guard against silent model +drift. The quality runners under `tests/quality/` already exist for +human inspection; this CLI wraps them into a single batch job suitable +for an unattended cron and exits non-zero on any regression. + +### What it runs + +- `resume_parser` — deterministic regex parser scorecard against the 15 + fixtures in `tests/quality/sample_resumes/`. Fast (<5s). +- `jd_parser` — deterministic JD parser scorecard against the 15 + fixtures in `tests/quality/sample_jds/`. Fast (<5s). +- `tailoring` — TailoringAgent against six (resume, JD) pairs. Runs in + deterministic fallback by default; opt into LLM mode with + `--include-llm` (~$0.05 / run). +- `review` — ReviewAgent on the three clean scenarios. Adversarial + scenarios stay in the dev workflow because they need an LLM to + produce stable approval-rate signals. +- `orchestrator_e2e` — full Tailoring → Review → ResumeGen → CoverLetter + chain. Requires `--include-llm` (~$0.20 / run); skipped otherwise. + +Each runner emits a single headline metric (typically average overall +score across fixtures) and a pass/fail bit. The script's exit code is +0 only when every runner passed AND no headline metric dropped by +more than `--regression-threshold` (default 5 percentage points). + +### Output + +`python -m backend.nightly_eval` prints a one-line JSON summary to +stdout and optionally writes it to `--output`. The JSON includes: + +```json +{ + "started_at": "2026-05-15T03:30:00Z", + "duration_seconds": 31.2, + "include_llm": true, + "regression_threshold": 0.05, + "runners": [{"name": "tailoring", "passed": true, "headline_metric": 0.83, ...}], + "failures": [], + "regressions": [], + "overall_pass": true +} +``` + +### VPS cron + +The backend container ships with `backend/nightly_eval.py` baked in. +Add this line to the host crontab so each night's run lands in the +shared log volume: + +``` +30 3 * * * docker exec ai-job-application-agent-api python -m backend.nightly_eval >> /var/log/aijobagent-nightly-eval.log 2>&1 +``` + +Run with `--include-llm` when the host has the OpenAI key configured +and you want full coverage: + +``` +30 3 * * * docker exec -e OPENAI_API_KEY=$(cat /etc/aijobagent/openai_key) ai-job-application-agent-api python -m backend.nightly_eval --include-llm --baseline /var/log/aijobagent-nightly-eval.last.json --output /var/log/aijobagent-nightly-eval.last.json >> /var/log/aijobagent-nightly-eval.log 2>&1 +``` + +Picking `--baseline` and `--output` to point at the same path makes +each night's run compare itself against the previous night's snapshot. +The first night runs without a baseline (treated as "no regression +check") and then writes one for the next night. + +### Alerting + +The script logs `nightly_eval_runner_finished` per runner and a single +`nightly_eval_failed` / `nightly_eval_regressed` warning at the end when +something is off. Two reasonable alerting hookups depending on what +the rest of the stack uses: + +- **Cheap path**: tail `/var/log/aijobagent-nightly-eval.log` from a + log shipper (Datadog, Promtail, Better Stack) and alert on + `"overall_pass": false` substrings. +- **Structured path**: an admin endpoint can read the last `--output` + JSON on demand. No new table needed — the run summary lives on + disk; the cost-tracking table (`aijobagent_run_traces`) covers the + per-call cost story separately. + +### Manual debugging + +Run it locally without LLM to spot fixture issues: + +``` +python -m backend.nightly_eval --runner resume_parser --runner jd_parser -v +``` + +To rerun a single runner with LLM mode after a regression alert: + +``` +python -m backend.nightly_eval --include-llm --runner tailoring -v +``` + +## Cost tracking (`aijobagent_run_traces`) + +Every successful LLM call records a row in `aijobagent_run_traces` with +prompt tokens, completion tokens, and a USD cost computed against the +pricing map in `src/openai_service.py`. See +`docs/sql/supabase-run-traces.sql` for the schema. Apply the migration +in the Supabase SQL editor before deploying the new backend bits — the +runtime tolerates a missing table (best-effort writes, no exception +propagated) but the cron-side tier-margin analysis assumes the table +exists. + +Pricing map and per-million-token costs are in `src/openai_service.py` +under `_MODEL_PRICING_USD_PER_MILLION`. Update both the prices in code +AND the README pricing reference when OpenAI changes a model's price. diff --git a/docs/sql/job_cache_cron_setup.sql b/docs/sql/job_cache_cron_setup.sql new file mode 100644 index 0000000..23f85c9 --- /dev/null +++ b/docs/sql/job_cache_cron_setup.sql @@ -0,0 +1,76 @@ +-- =========================================================================== +-- One-time setup for the cached_jobs 30-minute refresh schedule. +-- +-- Prereqs (already done by migrations): +-- - pg_cron extension installed (Supabase enables by default). +-- - pg_net extension enabled (see migration enable_pg_net_for_cached_jobs_cron). +-- - cached_jobs table exists (see migration create_cached_jobs_table). +-- - Backend deployed with REFRESH_CACHE_SECRET + SUPABASE_SERVICE_ROLE_KEY +-- in env, and the /api/admin/refresh-cache endpoint responding. +-- +-- HOW TO USE: +-- 1. Replace the two placeholders below with YOUR values: +-- e.g. https://api.job-application-copilot.xyz +-- e.g. the same string you set in backend env +-- 2. Paste the SELECT statements into the Supabase SQL Editor and run. +-- (Don't run this file as a migration — the schedule shouldn't be +-- tracked in version control, and the schedule call is idempotent +-- via the unique 'jobname' string anyway.) +-- +-- TO PAUSE: SELECT cron.unschedule('cached_jobs_refresh_30min'); +-- TO INSPECT: SELECT * FROM cron.job; +-- TO INSPECT RUNS: SELECT * FROM cron.job_run_details +-- WHERE jobname = 'cached_jobs_refresh_30min' +-- ORDER BY start_time DESC LIMIT 20; +-- =========================================================================== + +-- 1. Schedule the refresh. Cron expression: "*/30 * * * *" = every 30 min. +SELECT cron.schedule( + 'cached_jobs_refresh_30min', + '*/30 * * * *', + $$ + SELECT net.http_post( + url := '/api/admin/refresh-cache', + headers := jsonb_build_object( + 'Authorization', 'Bearer ', + 'Content-Type', 'application/json' + ), + timeout_milliseconds := 120000 -- 2 min — refresh can take a minute on a 100+ board run + ); + $$ +); + +-- 2. (Optional) Trigger an immediate first refresh so the cache has data +-- before waiting for the next 30-min tick. Same SQL the cron will run. +SELECT net.http_post( + url := '/api/admin/refresh-cache', + headers := jsonb_build_object( + 'Authorization', 'Bearer ', + 'Content-Type', 'application/json' + ), + timeout_milliseconds := 120000 +); + +-- 3. Verify the schedule landed. +SELECT jobname, schedule, command FROM cron.job WHERE jobname = 'cached_jobs_refresh_30min'; + +-- =========================================================================== +-- TROUBLESHOOTING +-- +-- Q: "I scheduled it but nothing's in cached_jobs." +-- A: Check `SELECT * FROM cron.job_run_details +-- WHERE jobname = 'cached_jobs_refresh_30min' +-- ORDER BY start_time DESC LIMIT 5;` +-- Look at status + return_message. Common causes: +-- - 401: REFRESH_CACHE_SECRET mismatch. Re-run with the right token. +-- - 503: backend env missing SUPABASE_SERVICE_ROLE_KEY. +-- - timeout: BACKEND_BASE_URL wrong, or backend cold-starting; bump +-- timeout_milliseconds. +-- +-- Q: "How do I roll back to live fan-out per request?" +-- A: SELECT cron.unschedule('cached_jobs_refresh_30min'); +-- The /jobs/search endpoint already supports `?live=true` for +-- cache-bypass. As long as the cache stops getting refreshed it'll +-- eventually empty itself out via the smart cleanup, OR you can +-- truncate manually: TRUNCATE TABLE public.cached_jobs; +-- =========================================================================== diff --git a/docs/sql/supabase-bootstrap.sql b/docs/sql/supabase-bootstrap.sql new file mode 100644 index 0000000..7610330 --- /dev/null +++ b/docs/sql/supabase-bootstrap.sql @@ -0,0 +1,258 @@ +create table if not exists public.app_users ( + id uuid primary key references auth.users (id) on delete cascade, + email text not null default '', + display_name text not null default '', + avatar_url text not null default '', + created_at timestamptz not null default timezone('utc', now()), + last_seen_at timestamptz not null default timezone('utc', now()), + plan_tier text not null default 'free', + account_status text not null default 'active' +); + +alter table public.app_users enable row level security; + +drop policy if exists "users can read own app_user record" on public.app_users; +create policy "users can read own app_user record" +on public.app_users +for select +to authenticated +using (auth.uid() = id); + +drop policy if exists "users can insert own app_user record" on public.app_users; +create policy "users can insert own app_user record" +on public.app_users +for insert +to authenticated +with check (auth.uid() = id); + +drop policy if exists "users can update own app_user record" on public.app_users; +create policy "users can update own app_user record" +on public.app_users +for update +to authenticated +using (auth.uid() = id) +with check (auth.uid() = id); + +create table if not exists public.usage_events ( + id bigint generated always as identity primary key, + user_id uuid not null references auth.users (id) on delete cascade, + task_name text not null default '', + model_name text not null default '', + request_count integer not null default 0, + prompt_tokens integer not null default 0, + completion_tokens integer not null default 0, + total_tokens integer not null default 0, + response_id text not null default '', + status text not null default '', + created_at timestamptz not null default timezone('utc', now()) +); + +create index if not exists usage_events_user_id_created_at_idx +on public.usage_events (user_id, created_at desc); + +alter table public.usage_events enable row level security; + +drop policy if exists "users can read own usage events" on public.usage_events; +create policy "users can read own usage events" +on public.usage_events +for select +to authenticated +using (auth.uid() = user_id); + +drop policy if exists "users can insert own usage events" on public.usage_events; +create policy "users can insert own usage events" +on public.usage_events +for insert +to authenticated +with check (auth.uid() = user_id); + +create or replace function public.get_daily_usage_totals( + target_user_id uuid, + target_window_start timestamptz default timezone('utc', now())::date, + target_window_end timestamptz default (timezone('utc', now())::date + interval '1 day') +) +returns table ( + request_count bigint, + prompt_tokens bigint, + completion_tokens bigint, + total_tokens bigint, + window_start timestamptz, + window_end timestamptz +) +language plpgsql +security invoker +set search_path = public +as $$ +begin + if auth.uid() is null or auth.uid() <> target_user_id then + raise exception 'Daily usage totals can only be read for the authenticated user.'; + end if; + + return query + select + coalesce(sum(usage_events.request_count), 0) as request_count, + coalesce(sum(usage_events.prompt_tokens), 0) as prompt_tokens, + coalesce(sum(usage_events.completion_tokens), 0) as completion_tokens, + coalesce(sum(usage_events.total_tokens), 0) as total_tokens, + target_window_start as window_start, + target_window_end as window_end + from public.usage_events + where usage_events.user_id = target_user_id + and usage_events.created_at >= target_window_start + and usage_events.created_at < target_window_end; +end; +$$; + +revoke all on function public.get_daily_usage_totals(uuid, timestamptz, timestamptz) from public; +grant execute on function public.get_daily_usage_totals(uuid, timestamptz, timestamptz) to authenticated; + +create table if not exists public.saved_workspaces ( + user_id uuid primary key references auth.users (id) on delete cascade, + job_title text not null default '', + workflow_signature text not null default '', + workflow_snapshot_json text not null default '', + cover_letter_payload_json text not null default '', + tailored_resume_payload_json text not null default '', + expires_at timestamptz not null, + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.saved_jobs ( + user_id uuid not null references auth.users (id) on delete cascade, + job_id text not null, + source text not null default '', + title text not null default '', + company text not null default '', + location text not null default '', + employment_type text not null default '', + url text not null default '', + summary text not null default '', + description_text text not null default '', + posted_at text not null default '', + scraped_at text not null default '', + metadata jsonb not null default '{}'::jsonb, + saved_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + primary key (user_id, job_id) +); + +alter table public.saved_workspaces add column if not exists job_title text not null default ''; +alter table public.saved_workspaces add column if not exists workflow_signature text not null default ''; +alter table public.saved_workspaces add column if not exists workflow_snapshot_json text not null default ''; +alter table public.saved_workspaces add column if not exists cover_letter_payload_json text not null default ''; +alter table public.saved_workspaces add column if not exists tailored_resume_payload_json text not null default ''; +alter table public.saved_workspaces add column if not exists expires_at timestamptz not null default timezone('utc', now()) + interval '1 day'; +alter table public.saved_workspaces add column if not exists updated_at timestamptz not null default timezone('utc', now()); +alter table public.saved_jobs add column if not exists source text not null default ''; +alter table public.saved_jobs add column if not exists title text not null default ''; +alter table public.saved_jobs add column if not exists company text not null default ''; +alter table public.saved_jobs add column if not exists location text not null default ''; +alter table public.saved_jobs add column if not exists employment_type text not null default ''; +alter table public.saved_jobs add column if not exists url text not null default ''; +alter table public.saved_jobs add column if not exists summary text not null default ''; +alter table public.saved_jobs add column if not exists description_text text not null default ''; +alter table public.saved_jobs add column if not exists posted_at text not null default ''; +alter table public.saved_jobs add column if not exists scraped_at text not null default ''; +alter table public.saved_jobs add column if not exists metadata jsonb not null default '{}'::jsonb; +alter table public.saved_jobs add column if not exists saved_at timestamptz not null default timezone('utc', now()); +alter table public.saved_jobs add column if not exists updated_at timestamptz not null default timezone('utc', now()); + +create index if not exists saved_workspaces_expires_at_idx +on public.saved_workspaces (expires_at); + +create index if not exists saved_jobs_user_id_saved_at_idx +on public.saved_jobs (user_id, saved_at desc); + +alter table public.saved_workspaces enable row level security; +alter table public.saved_jobs enable row level security; + +create extension if not exists pg_cron with schema extensions; + +-- Note: the saved-workspaces retention path was originally a SQL-only +-- sweeper (cleanup_expired_saved_workspaces RPC) running on a 5-minute +-- pg_cron schedule. Step 8 of the tier-enforcement series replaced +-- that with a Python sweeper in backend/maintenance.py that: +-- 1. Does what this RPC did (DELETE expired saved_workspaces rows) +-- 2. Is tier-aware (Free 7d / Pro 30d / Business unbounded) instead +-- of the single hardcoded expires_at-based deletion +-- 3. Routes through resolve_user_tier so payment integration flips +-- retention semantics with a single switch +-- +-- The Python sweeper is scheduled via VPS crontab (daily): +-- 17 3 * * * cd /app && python -m backend.maintenance >> /var/log/maintenance.log 2>&1 +-- +-- Both running in parallel would race: pg_cron could delete a row +-- before the Python sweeper iterated it, breaking tier semantics for +-- Business users whose expires_at was set under the old default. +-- +-- Applied to prod by Supabase migration `drop_legacy_saved_workspaces_cleanup` +-- (20260514183110). Git history of this file preserves the original +-- RPC + cron block before the cleanup. + +drop policy if exists "users can read own saved workspace" on public.saved_workspaces; +create policy "users can read own saved workspace" +on public.saved_workspaces +for select +to authenticated +using ( + auth.uid() = user_id + and expires_at > timezone('utc', now()) +); + +drop policy if exists "users can insert own saved workspace" on public.saved_workspaces; +create policy "users can insert own saved workspace" +on public.saved_workspaces +for insert +to authenticated +with check (auth.uid() = user_id); + +drop policy if exists "users can update own saved workspace" on public.saved_workspaces; +create policy "users can update own saved workspace" +on public.saved_workspaces +for update +to authenticated +using (auth.uid() = user_id) +with check (auth.uid() = user_id); + +drop policy if exists "users can delete own saved workspace" on public.saved_workspaces; +create policy "users can delete own saved workspace" +on public.saved_workspaces +for delete +to authenticated +using (auth.uid() = user_id); + +drop policy if exists "users can read own saved jobs" on public.saved_jobs; +create policy "users can read own saved jobs" +on public.saved_jobs +for select +to authenticated +using (auth.uid() = user_id); + +drop policy if exists "users can insert own saved jobs" on public.saved_jobs; +create policy "users can insert own saved jobs" +on public.saved_jobs +for insert +to authenticated +with check (auth.uid() = user_id); + +drop policy if exists "users can update own saved jobs" on public.saved_jobs; +create policy "users can update own saved jobs" +on public.saved_jobs +for update +to authenticated +using (auth.uid() = user_id) +with check (auth.uid() = user_id); + +drop policy if exists "users can delete own saved jobs" on public.saved_jobs; +create policy "users can delete own saved jobs" +on public.saved_jobs +for delete +to authenticated +using (auth.uid() = user_id); + +-- The legacy `cleanup-expired-saved-workspaces` pg_cron job + manual +-- invocation (formerly here) were removed alongside the RPC. See the +-- comment above the (removed) cleanup_expired_saved_workspaces RPC +-- block earlier in this file for the rationale and replacement path. +-- The VPS-side Python sweeper at backend/maintenance.py is the +-- single source of truth for saved_workspaces retention. diff --git a/docs/sql/supabase-quota-counters.sql b/docs/sql/supabase-quota-counters.sql new file mode 100644 index 0000000..d972f08 --- /dev/null +++ b/docs/sql/supabase-quota-counters.sql @@ -0,0 +1,150 @@ +-- AI Job Agent per-(user, period, counter) quota counters table + atomic +-- increment RPC. +-- +-- Apply this in the Supabase SQL editor alongside docs/sql/supabase-bootstrap.sql. +-- Step 2 of the tier-enforcement series. Provides: +-- * aijobagent_quota_counters table -- one row per (user, period, counter) +-- * increment_aijobagent_counter RPC that atomically UPSERTs and returns the +-- new value +-- * RLS policy so users can only read their own counters +-- +-- HelpmateAI's helpmate_quota_counters table uses a fixed two-column schema +-- (questions, premium) because that backend has exactly two counters. AI Job +-- Agent has eight different counters with mixed period semantics (some +-- monthly, some lifetime, some persistent), so the schema here generalizes: +-- counter_name is part of the composite PK and the period_key is a string +-- the application supplies (YYYY-MM for monthly, "lifetime" for lifetime +-- counters, "persistent" reserved for persistent caps not yet wired). + +create table if not exists public.aijobagent_quota_counters ( + user_id uuid not null references auth.users (id) on delete cascade, + period_key text not null, + counter_name text not null, + count integer not null default 0, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + primary key (user_id, period_key, counter_name) +); + +create index if not exists aijobagent_quota_counters_user_id_idx +on public.aijobagent_quota_counters (user_id); + +-- RLS: a user can read their own counter rows. INSERT/UPDATE flow through +-- the RPC below (security definer) so they don't need direct write policies. +-- The service role still bypasses RLS for ops. +alter table public.aijobagent_quota_counters enable row level security; + +drop policy if exists "users can read own aijobagent quota counters" +on public.aijobagent_quota_counters; +create policy "users can read own aijobagent quota counters" +on public.aijobagent_quota_counters +for select +to authenticated +using (auth.uid() = user_id); + +-- --------------------------------------------------------------------------- +-- Atomic increment RPC. +-- +-- Pattern: INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING. PostgreSQL +-- guarantees the upsert is atomic, so two concurrent workspace runs from the +-- same user produce two distinct return values (N+1 and N+2) without race. +-- The optional p_delta argument lets the refund path decrement by 1 by +-- passing -1 -- a separate "decrement" RPC would duplicate the same body. +-- +-- The cap check intentionally happens in the SQL function so we never write +-- a row that would exceed the user's tier limit. On rejection the function +-- raises a SQLSTATE 'P0001' error with a stable detail string; the Python +-- wrapper catches that and translates it to a QuotaExceededError, which the +-- FastAPI exception handler converts to a structured 429 response. +-- --------------------------------------------------------------------------- + +create or replace function public.increment_aijobagent_counter( + p_user_id uuid, + p_period_key text, + p_counter_name text, + p_cap integer, + p_delta integer default 1 +) +returns integer +language plpgsql +security definer +set search_path = public +as $$ +declare + new_count integer; + existing_count integer; +begin + if p_delta = 0 then + select count into new_count + from public.aijobagent_quota_counters + where user_id = p_user_id + and period_key = p_period_key + and counter_name = p_counter_name; + return coalesce(new_count, 0); + end if; + + -- Cap=-1 means "unlimited"; never write a row, just acknowledge. The + -- Python helper short-circuits this same case before calling the RPC, + -- but defending here makes the SQL function safe to call directly from + -- an admin context too. + if p_cap < 0 then + insert into public.aijobagent_quota_counters + (user_id, period_key, counter_name, count) + values (p_user_id, p_period_key, p_counter_name, greatest(p_delta, 0)) + on conflict (user_id, period_key, counter_name) + do update set + count = greatest(aijobagent_quota_counters.count + p_delta, 0), + updated_at = timezone('utc', now()) + returning count into new_count; + return new_count; + end if; + + -- Cap enforcement only fires on positive delta. Refunds (negative delta) + -- always succeed -- they only run after a successful increment, so the + -- counter is guaranteed to be >= 1 and we floor at zero defensively. + if p_delta > 0 then + select count into existing_count + from public.aijobagent_quota_counters + where user_id = p_user_id + and period_key = p_period_key + and counter_name = p_counter_name + for update; + + existing_count := coalesce(existing_count, 0); + if existing_count + p_delta > p_cap then + raise exception 'aijobagent_quota_exceeded' + using errcode = 'P0001', + detail = format( + 'counter=%s cap=%s current=%s', + p_counter_name, p_cap, existing_count + ); + end if; + end if; + + insert into public.aijobagent_quota_counters + (user_id, period_key, counter_name, count) + values (p_user_id, p_period_key, p_counter_name, greatest(p_delta, 0)) + on conflict (user_id, period_key, counter_name) + do update set + count = greatest(aijobagent_quota_counters.count + p_delta, 0), + updated_at = timezone('utc', now()) + returning count into new_count; + + return new_count; +end; +$$; + +-- Lock down execution. The RPC takes user_id as a parameter rather than +-- consulting auth.uid(), so granting EXECUTE to authenticated would let any +-- signed-in user burn another user's quota by passing their UUID. The +-- backend uses the service-role key for this RPC so it can call the +-- function while client-side calls cannot. +revoke all on function + public.increment_aijobagent_counter(uuid, text, text, integer, integer) +from public; +revoke all on function + public.increment_aijobagent_counter(uuid, text, text, integer, integer) +from authenticated; +grant execute on function + public.increment_aijobagent_counter(uuid, text, text, integer, integer) +to service_role; diff --git a/docs/sql/supabase-resume-builder.sql b/docs/sql/supabase-resume-builder.sql new file mode 100644 index 0000000..affdb3e --- /dev/null +++ b/docs/sql/supabase-resume-builder.sql @@ -0,0 +1,104 @@ +-- Resume builder sessions table. One row per user (PK = user_id). +-- The application upserts on every chat turn / draft-save; the row +-- is GC'd after RESUME_BUILDER_SESSION_TTL_DAYS (default 7) of +-- inactivity by the cron at the bottom of this file. + +create table if not exists public.resume_builder_sessions ( + user_id uuid primary key references auth.users (id) on delete cascade, + session_id text not null, + status text not null default 'collecting', + current_step text not null default 'basics', + session_payload_json text not null default '', + updated_at timestamptz not null default timezone('utc', now()), + -- Refreshed by the application on every save_session upsert + -- (timestamp + RESUME_BUILDER_SESSION_TTL_DAYS). Default is + -- only used by the column itself for new rows in case the + -- writer ever forgets to set it. + expires_at timestamptz not null + default timezone('utc', now()) + interval '7 days' +); + +create index if not exists resume_builder_sessions_updated_at_idx + on public.resume_builder_sessions (updated_at desc); + +create index if not exists resume_builder_sessions_expires_at_idx + on public.resume_builder_sessions (expires_at); + +alter table public.resume_builder_sessions enable row level security; + +-- SELECT also filters out expired rows so a draft past its TTL reads +-- as not-existing for the user even before the cron physically +-- removes it. +drop policy if exists "Users can view their own resume builder draft" + on public.resume_builder_sessions; +create policy "Users can view their own resume builder draft" + on public.resume_builder_sessions + for select + using (auth.uid() = user_id and expires_at > timezone('utc', now())); + +drop policy if exists "Users can insert their own resume builder draft" + on public.resume_builder_sessions; +create policy "Users can insert their own resume builder draft" + on public.resume_builder_sessions + for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can update their own resume builder draft" + on public.resume_builder_sessions; +create policy "Users can update their own resume builder draft" + on public.resume_builder_sessions + for update + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +drop policy if exists "Users can delete their own resume builder draft" + on public.resume_builder_sessions; +create policy "Users can delete their own resume builder draft" + on public.resume_builder_sessions + for delete + using (auth.uid() = user_id); + +-- Physical cleanup. SECURITY DEFINER so the cron job (run as the +-- cron owner, not as a logged-in user) can DELETE rows even though +-- normal RLS would scope to auth.uid(). +create or replace function public.cleanup_expired_resume_builder_sessions() +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +begin + delete from public.resume_builder_sessions + where expires_at <= timezone('utc', now()); +end; +$$; + +-- Lock down execution. Without this, Supabase grants EXECUTE on +-- public-schema functions to PUBLIC, anon, and authenticated by +-- default — meaning any caller with the public anon key could trigger +-- arbitrary expired-session cleanup via the REST RPC surface. The +-- pg_cron job runs as the cron owner (postgres) so the unschedule/ +-- schedule below still work; the backend never calls this function +-- directly. (Mirrored on prod by migration `revoke_public_from_existing_definer_rpcs`.) +revoke all on function public.cleanup_expired_resume_builder_sessions() from public; +revoke all on function public.cleanup_expired_resume_builder_sessions() from anon; +revoke all on function public.cleanup_expired_resume_builder_sessions() from authenticated; + +-- Reset the cron schedule idempotently so re-running this file +-- doesn't queue duplicate jobs. +do $$ +begin + if exists ( + select 1 from cron.job + where jobname = 'cleanup-expired-resume-builder-sessions' + ) then + perform cron.unschedule('cleanup-expired-resume-builder-sessions'); + end if; +end +$$; + +select cron.schedule( + 'cleanup-expired-resume-builder-sessions', + '*/5 * * * *', + $$select public.cleanup_expired_resume_builder_sessions();$$ +); diff --git a/docs/sql/supabase-run-traces.sql b/docs/sql/supabase-run-traces.sql new file mode 100644 index 0000000..92cce08 --- /dev/null +++ b/docs/sql/supabase-run-traces.sql @@ -0,0 +1,83 @@ +-- AI Job Agent per-LLM-call trace table for cost-per-query and tier-margin +-- validation. +-- +-- Apply this in the Supabase SQL editor alongside the other migrations under +-- docs/sql/. Step 3 of the production-safety pack. Provides: +-- * aijobagent_run_traces table -- one row per LLM call recorded by +-- `src.openai_service`, with prompt + completion tokens and a USD cost +-- computed against the pricing map in the same module. +-- * RLS so authenticated users can read their own rows, while writes are +-- service-role-only (parity with `aijobagent_quota_counters`). +-- +-- The point: the existing `usage_events` table records per-call token usage +-- but no cost; computing cost client-side at read time is brittle when +-- OpenAI moves pricing. Recording USD at write time lets the cron-side +-- nightly-eval (and any future tier-margin dashboard) compare actuals to +-- the modeled COGS without re-deriving prices. +-- +-- Schema decisions: +-- * trace_id is a UUID so the cost row can be referenced from logs / a +-- future tier-margin dashboard without exposing the underlying auto- +-- increment surface. +-- * cost_usd uses numeric(10,6) to capture sub-cent costs without +-- floating-point drift; the pricing map in openai_service ranges from +-- $0.10/1M (gpt-5.4-nano) up through $30/1M (gpt-5.5 output), so a +-- 5-bullet workflow run lands in the $0.001 - $0.05 band. +-- * task_name is text rather than an enum because new agents land +-- frequently; constraining to an enum would block migrations behind +-- a backfill every time we add one. + +create table if not exists public.aijobagent_run_traces ( + trace_id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete cascade, + task_name text not null, + model_name text not null, + prompt_tokens int not null default 0, + completion_tokens int not null default 0, + cost_usd numeric(10, 6) not null default 0, + success boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +-- Hot reads: per-user cost summaries for the upcoming tier-margin endpoint; +-- per-day cost rollups for the nightly cost-of-goods report. Both filter on +-- (user_id, created_at) so a single composite index covers both queries. +create index if not exists aijobagent_run_traces_user_id_created_at_idx +on public.aijobagent_run_traces (user_id, created_at desc); + +-- Per-task cost diagnostics: when a tailoring run looks expensive we want +-- to slice by task_name (tailoring vs review vs resume_generation) over +-- a rolling window. The single-column index on task_name is cheap and +-- makes the GROUP BY work without a sequential scan. +create index if not exists aijobagent_run_traces_task_name_idx +on public.aijobagent_run_traces (task_name); + +-- --------------------------------------------------------------------------- +-- RLS +-- +-- Reads: authenticated users see their own rows ONLY. This mirrors the +-- pattern from `aijobagent_quota_counters` -- the per-user table is queryable +-- from the UI for the user's own diagnostics, but the writer is service-role +-- because the application records the trace from inside OpenAIService where +-- the user identity is already known. +-- +-- Writes: no policy granted for `authenticated`. The service role bypasses +-- RLS, which is how `record_trace` in `backend.run_traces` inserts rows. +-- Granting write to `authenticated` would let any signed-in user fake their +-- own cost rows -- not catastrophic, but a useless surface. +-- --------------------------------------------------------------------------- + +alter table public.aijobagent_run_traces enable row level security; + +drop policy if exists "users can read own aijobagent run traces" +on public.aijobagent_run_traces; +create policy "users can read own aijobagent run traces" +on public.aijobagent_run_traces +for select +to authenticated +using (auth.uid() = user_id); + +-- No insert / update / delete policies for `authenticated`. The application +-- writes via the service-role client (`backend.run_traces.record_trace`), +-- which bypasses RLS. Direct table writes from the frontend / a signed-in +-- session must fail with a permission error. diff --git a/docs/sql/supabase-saved-jobs.sql b/docs/sql/supabase-saved-jobs.sql new file mode 100644 index 0000000..e3fc82a --- /dev/null +++ b/docs/sql/supabase-saved-jobs.sql @@ -0,0 +1,52 @@ +create table if not exists public.saved_jobs ( + user_id uuid not null references auth.users (id) on delete cascade, + job_id text not null, + source text not null default '', + title text not null default '', + company text not null default '', + location text not null default '', + employment_type text not null default '', + url text not null default '', + summary text not null default '', + description_text text not null default '', + posted_at text not null default '', + scraped_at text not null default '', + metadata jsonb not null default '{}'::jsonb, + saved_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + primary key (user_id, job_id) +); + +create index if not exists saved_jobs_user_id_saved_at_idx +on public.saved_jobs (user_id, saved_at desc); + +alter table public.saved_jobs enable row level security; + +drop policy if exists "users can read own saved jobs" on public.saved_jobs; +create policy "users can read own saved jobs" +on public.saved_jobs +for select +to authenticated +using (auth.uid() = user_id); + +drop policy if exists "users can insert own saved jobs" on public.saved_jobs; +create policy "users can insert own saved jobs" +on public.saved_jobs +for insert +to authenticated +with check (auth.uid() = user_id); + +drop policy if exists "users can update own saved jobs" on public.saved_jobs; +create policy "users can update own saved jobs" +on public.saved_jobs +for update +to authenticated +using (auth.uid() = user_id) +with check (auth.uid() = user_id); + +drop policy if exists "users can delete own saved jobs" on public.saved_jobs; +create policy "users can delete own saved jobs" +on public.saved_jobs +for delete +to authenticated +using (auth.uid() = user_id); diff --git a/docs/sql/supabase-subscriptions.sql b/docs/sql/supabase-subscriptions.sql new file mode 100644 index 0000000..c3e3248 --- /dev/null +++ b/docs/sql/supabase-subscriptions.sql @@ -0,0 +1,105 @@ +-- AI Job Agent subscriptions table. +-- +-- Apply this in the Supabase SQL editor alongside docs/sql/supabase-bootstrap.sql +-- and docs/sql/supabase-quota-counters.sql. Adds the row that +-- `backend.subscriptions.get_active_subscription` reads from, which in +-- turn drives the Lemon Squeezy-powered `resolve_user_tier`. +-- +-- One row per user. The webhook upserts on `processor_subscription_id` +-- and replaces the row's `user_id`/`tier`/`status` columns; the +-- application reads the row by user_id, which is the PK. Multi-seat +-- Business subscriptions (one LS subscription, many users) are out of +-- scope for v1 — when that ships, drop the `user_id PRIMARY KEY` and +-- add a separate `subscription_seats` table. +-- +-- Same style as supabase-quota-counters.sql: +-- * RLS so users can read their own row. +-- * No client-side write policies — webhook writes via service_role. +-- * Service_role bypasses RLS so the FastAPI service can read on +-- every gate check without a JWT round-trip. + +create table if not exists public.subscriptions ( + user_id uuid primary key references auth.users (id) on delete cascade, + -- "lemonsqueezy" today, "stripe" / "razorpay" stubs reserved so a + -- future payment processor swap doesn't need a schema migration — + -- just a new `resolve_user_tier` branch. + processor text not null check (processor in ('lemonsqueezy', 'stripe', 'razorpay')), + -- LS-side identifiers. processor_customer_id is the LS "customer" + -- (one per email) so a portal redirect can target the right + -- account. processor_subscription_id is unique across all + -- processors -- it's the natural idempotency key for webhook + -- upserts. + processor_customer_id text, + processor_subscription_id text not null unique, + tier text not null check (tier in ('pro', 'business')), + -- LS subscription statuses we care about. "active" / "past_due" / + -- "cancelled" / "expired" / "paused" mirror the LS status enum; + -- `resolve_user_tier` decides which of these still grant tier + -- access based on current_period_end. + status text not null check (status in ('active', 'past_due', 'cancelled', 'expired', 'paused')), + -- LS sends this as a Unix timestamp in the renewal-related + -- webhooks. We store the parsed timestamptz so the tier resolver + -- can do a `current_period_end > now()` comparison without + -- re-parsing on every read. + current_period_end timestamptz, + -- LS "cancelled" status means "user clicked cancel but tier + -- access continues until period end". Distinct from + -- `status='cancelled'` -- LS sets cancel_at_period_end=true but + -- keeps status='active' on the data payload. The webhook router + -- in backend/webhooks/lemonsqueezy.py mirrors that. + cancel_at_period_end boolean not null default false, + -- The LS variant_id that produced this subscription. Lets us + -- recover the tier from the upstream config (env var mapping) + -- without a database round-trip; the resolver reads `tier` + -- directly because the variant→tier mapping is settled at + -- webhook-write time. + variant_id text, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create index if not exists subscriptions_processor_subscription_id_idx +on public.subscriptions (processor_subscription_id); + +-- RLS: a user can read their own subscription row. The webhook +-- handler uses the service-role key which bypasses RLS, so no INSERT +-- / UPDATE policies are needed at the row level. Granting write +-- policies to authenticated would let a signed-in user forge their +-- own subscription -- explicitly avoided. +alter table public.subscriptions enable row level security; + +drop policy if exists "users can read own subscription" +on public.subscriptions; +create policy "users can read own subscription" +on public.subscriptions +for select +to authenticated +using (auth.uid() = user_id); + +-- Webhook idempotency log. The LS webhook router writes one row per +-- processed event_id; repeated deliveries (LS retries on 5xx + at- +-- least-once semantics) are detected by the PK uniqueness check and +-- skipped before any state change. +-- +-- Kept in a separate table so the subscriptions row stays compact and +-- so retention sweeps can prune the log independently. The log row +-- has no FK to subscriptions -- some events (cancelled-then-resumed, +-- unknown-variant) don't always carry a user_id we trust at write +-- time. +create table if not exists public.subscription_webhook_log ( + event_id text primary key, + event_name text not null, + received_at timestamptz not null default timezone('utc', now()) +); + +create index if not exists subscription_webhook_log_received_at_idx +on public.subscription_webhook_log (received_at); + +-- Webhook log writes are service-role only. Authenticated users have +-- no reason to read or write -- the log exists for backend +-- idempotency, not user-visible history. +alter table public.subscription_webhook_log enable row level security; + +-- No SELECT policy created -> authenticated callers see zero rows. +-- service_role bypasses RLS so the webhook router can still read + +-- insert freely. diff --git a/docs/static/.gitkeep b/docs/static/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/static/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/static/demo_job_description/Job_Title_Data_Analyst.txt b/docs/static/demo_job_description/Job_Title_Data_Analyst.txt new file mode 100644 index 0000000..25f910a --- /dev/null +++ b/docs/static/demo_job_description/Job_Title_Data_Analyst.txt @@ -0,0 +1,43 @@ +Job Title: Data Analyst (Contractual Role) + +Location: Flipkart Office, Embassy Tech Village, Devarabeesanahalli, Bellandur, + +Bangalore + +Type: Contractual (Extendable) + +Experience: Minimum 1 Year + +Budget: ₹4.5 LPA + +Work Days: 6 Days a Week + +Joining: Immediate Joiner Preferred + +Key Responsibilities: + +● Leverage Advanced Excel for data management and reporting. + +● Develop insights using Python for data processing and analytics. + +● Build interactive dashboards and reports using Power BI. + +● Collaborate with cross-functional teams to support business decision-making. + +● Maintain high data accuracy and consistency in deliverables. + +Requirements: + +● Minimum 1 year of relevant experience in data analysis or business + +intelligence. + +● Strong skills in Advanced Excel, Python, and Power BI. + +● Excellent verbal and written communication skills. + +● Ability to work full-time from the Bangalore office. + +● Open to a contractual role with potential for extension based on performance + +and project needs \ No newline at end of file diff --git a/docs/static/demo_job_description/Sample_Job_Description_DataAnalyst.docx b/docs/static/demo_job_description/Sample_Job_Description_DataAnalyst.docx new file mode 100644 index 0000000..3f481dc Binary files /dev/null and b/docs/static/demo_job_description/Sample_Job_Description_DataAnalyst.docx differ diff --git a/docs/static/demo_job_description/Sample_Job_Description_DataScientist.txt b/docs/static/demo_job_description/Sample_Job_Description_DataScientist.txt new file mode 100644 index 0000000..e3850f0 --- /dev/null +++ b/docs/static/demo_job_description/Sample_Job_Description_DataScientist.txt @@ -0,0 +1,15 @@ +Data Scientist - Predictive Modeling + +Location: Bangalore, India + +We are looking for a Data Scientist with 3+ years of experience in machine learning, predictive analytics, and data engineering. + +Responsibilities: +- Build and validate predictive models using Python and scikit-learn +- Work with SQL databases and big data frameworks +- Collaborate with cross-functional teams + +Skills: +- Python, SQL, Pandas, NumPy +- Scikit-learn, XGBoost, TensorFlow (optional) +- Excellent problem-solving and communication skills diff --git a/docs/static/demo_job_description/Sample_Job_Description_MLEngineer.pdf b/docs/static/demo_job_description/Sample_Job_Description_MLEngineer.pdf new file mode 100644 index 0000000..bd8d3f8 Binary files /dev/null and b/docs/static/demo_job_description/Sample_Job_Description_MLEngineer.pdf differ diff --git a/docs/static/demo_resume/.gitkeep b/docs/static/demo_resume/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/static/demo_resume/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/static/demo_resume/Black White Beige Simple Modern Tech Resume.pdf b/docs/static/demo_resume/Black White Beige Simple Modern Tech Resume.pdf new file mode 100644 index 0000000..8f253da Binary files /dev/null and b/docs/static/demo_resume/Black White Beige Simple Modern Tech Resume.pdf differ diff --git a/docs/static/demo_resume/Black and White Modern Professional Resume.pdf b/docs/static/demo_resume/Black and White Modern Professional Resume.pdf new file mode 100644 index 0000000..7a29221 Binary files /dev/null and b/docs/static/demo_resume/Black and White Modern Professional Resume.pdf differ diff --git a/docs/static/demo_resume/Blue Geometric Lines Professional UX Design Tech Resume.pdf b/docs/static/demo_resume/Blue Geometric Lines Professional UX Design Tech Resume.pdf new file mode 100644 index 0000000..9c14db4 Binary files /dev/null and b/docs/static/demo_resume/Blue Geometric Lines Professional UX Design Tech Resume.pdf differ diff --git a/docs/static/demo_resume/Blue and White Professional Resume.pdf b/docs/static/demo_resume/Blue and White Professional Resume.pdf new file mode 100644 index 0000000..1b6f69f Binary files /dev/null and b/docs/static/demo_resume/Blue and White Professional Resume.pdf differ diff --git a/docs/static/demo_resume/Leander_Antony_Enhanced_Resume_updated.docx b/docs/static/demo_resume/Leander_Antony_Enhanced_Resume_updated.docx new file mode 100644 index 0000000..a4e7ff1 Binary files /dev/null and b/docs/static/demo_resume/Leander_Antony_Enhanced_Resume_updated.docx differ diff --git a/docs/static/demo_resume/Leander_Antony_Resume_International.docx b/docs/static/demo_resume/Leander_Antony_Resume_International.docx new file mode 100644 index 0000000..3a6d3b5 Binary files /dev/null and b/docs/static/demo_resume/Leander_Antony_Resume_International.docx differ diff --git a/docs/static/demo_resume/Purple and White Clean and Professional Resume.pdf b/docs/static/demo_resume/Purple and White Clean and Professional Resume.pdf new file mode 100644 index 0000000..c276a7b Binary files /dev/null and b/docs/static/demo_resume/Purple and White Clean and Professional Resume.pdf differ diff --git a/docs/static/pdf_rendered/classic_resume_render.pdf b/docs/static/pdf_rendered/classic_resume_render.pdf new file mode 100644 index 0000000..cd920ee Binary files /dev/null and b/docs/static/pdf_rendered/classic_resume_render.pdf differ diff --git a/docs/static/pdf_rendered/cover_letter_render.pdf b/docs/static/pdf_rendered/cover_letter_render.pdf new file mode 100644 index 0000000..42672a4 Binary files /dev/null and b/docs/static/pdf_rendered/cover_letter_render.pdf differ diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ac9b77f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,9 @@ +# Local development keeps the frontend calling the backend through a same-origin rewrite. +NEXT_PUBLIC_API_BASE_URL=/api +API_REWRITE_TARGET=http://127.0.0.1:8000 +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# Production example once the FastAPI backend is on the VPS: +# NEXT_PUBLIC_API_BASE_URL=/api +# API_REWRITE_TARGET=https://api.example.com +# NEXT_PUBLIC_SITE_URL=https://app.example.com diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..bf69b17 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": ["next/core-web-vitals"], + "ignorePatterns": [ + ".next/**", + "node_modules/**", + "out/**", + "tsconfig.tsbuildinfo" + ] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..6f9180e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +.next/ +node_modules/ +.env.local + +.vercel diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d4e5154 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,54 @@ +# Frontend + +This `frontend/` app is now the live `Next.js` workspace for the Streamlit-to-Vercel migration. + +The current workspace includes: + +- account-aware Google sign-in and session restore +- resume upload and parsing +- job search plus direct job URL import +- manual JD upload and editing +- deterministic preview and agentic analysis +- shortlist persistence and latest saved-workspace reload +- grounded assistant chat scoped to the active workspace +- artifact preview plus Markdown, PDF, and ZIP package exports + +## Local Development + +1. Start the backend from the repo root: + +```powershell +uv run uvicorn backend.app:app --reload --host 127.0.0.1 --port 8000 +``` + +2. In this `frontend/` directory: + +```powershell +npm install +npm run dev +``` + +3. Open [http://localhost:3000](http://localhost:3000) + +## Environment + +Copy `frontend/.env.example` into a local `.env.local` and set: + +- `NEXT_PUBLIC_API_BASE_URL=/api` +- `API_REWRITE_TARGET=http://127.0.0.1:8000` for local backend development +- `NEXT_PUBLIC_SITE_URL=http://localhost:3000` + +For Vercel production: + +- keep `NEXT_PUBLIC_API_BASE_URL=/api` +- point `API_REWRITE_TARGET` at the VPS FastAPI origin, for example `https://api.example.com` +- set `NEXT_PUBLIC_SITE_URL` to the Vercel workspace URL +- this mirrors the HelpMate setup, where the frontend stays same-origin on Vercel and the actual backend host is hidden behind the rewrite target + +## Deployment Notes + +- Add the Vercel workspace URL to Supabase allowed redirect URLs because Google sign-in returns to `/workspace`. +- Keep the backend CORS list aligned with the Vercel domain and any custom domain you place in front of it. +- On the VPS, set `AI_JOB_APPLICATION_API_DOMAIN` in `backend/vps/.env` so Caddy serves the FastAPI container on the final API subdomain. +- Because this app shares the same VPS as HelpMate, do not run two separate public Caddy stacks on `80/443`. The safer production shape is one shared ingress proxy routing multiple domains or subdomains to separate app containers. +- The frontend is build-verified with `npm run build`; the remaining work after code merge is hosted QA across real env vars and auth callbacks. diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..cb7d997 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,18 @@ +import type { NextConfig } from "next"; + +const apiRewriteTarget = + process.env.API_REWRITE_TARGET ?? "http://127.0.0.1:8000"; + +const nextConfig: NextConfig = { + allowedDevOrigins: ["localhost", "127.0.0.1"], + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${apiRewriteTarget}/api/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ca57146 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5870 @@ +{ + "name": "ai-job-application-agent-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-job-application-agent-frontend", + "version": "0.1.0", + "dependencies": { + "@vercel/analytics": "^2.0.1", + "next": "16.2.2", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.5", + "typescript": "^5.9.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", + "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.5.tgz", + "integrity": "sha512-LY3btOpPh+OTIpviNojDpUdIbHW9j0JBYBjsIp8IxtDFfYFyORvw3yNq6N231FVqQA7n7lwaf7xHbVJlA1ED7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", + "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", + "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", + "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", + "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", + "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", + "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", + "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", + "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vercel/analytics": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", + "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==", + "license": "MIT", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.22.tgz", + "integrity": "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.5.tgz", + "integrity": "sha512-zogs9zlOiZ7ka+wgUnmcM0KBEDjo4Jis7kxN1jvC0N4wynQ2MIx/KBkg4mVF63J5EK4W0QMCn7xO3vNisjaAoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.2.5", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", + "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.2", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.2", + "@next/swc-darwin-x64": "16.2.2", + "@next/swc-linux-arm64-gnu": "16.2.2", + "@next/swc-linux-arm64-musl": "16.2.2", + "@next/swc-linux-x64-gnu": "16.2.2", + "@next/swc-linux-x64-musl": "16.2.2", + "@next/swc-win32-arm64-msvc": "16.2.2", + "@next/swc-win32-x64-msvc": "16.2.2", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a6a10df --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "ai-job-application-agent-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint ." + }, + "dependencies": { + "@vercel/analytics": "^2.0.1", + "next": "16.2.2", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.5", + "typescript": "^5.9.3" + } +} diff --git a/frontend/public/brand/job-copilot-logo.png b/frontend/public/brand/job-copilot-logo.png new file mode 100644 index 0000000..421b770 Binary files /dev/null and b/frontend/public/brand/job-copilot-logo.png differ diff --git a/frontend/public/landing/hero-workspace.png b/frontend/public/landing/hero-workspace.png new file mode 100644 index 0000000..a6475c5 Binary files /dev/null and b/frontend/public/landing/hero-workspace.png differ diff --git a/frontend/src/app/apple-icon.png b/frontend/src/app/apple-icon.png new file mode 100644 index 0000000..fa51fe6 Binary files /dev/null and b/frontend/src/app/apple-icon.png differ diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..3dfe59e Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..c853c40 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,8106 @@ +:root { + --background: #000000; + --surface: rgba(8, 10, 16, 0.92); + --surface-strong: rgba(5, 7, 12, 0.97); + --border: rgba(148, 166, 207, 0.18); + --text: #f5f8ff; + --muted: #bec9de; + --accent: #3064ff; + --accent-strong: #4171ff; + --accent-soft: rgba(48, 100, 255, 0.14); + --success: #7fe0b0; + --warning: #ffcb94; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(180deg, rgba(90, 124, 255, 0.035), transparent 10%), + linear-gradient(180deg, #070b14 0%, #03050a 42%, #000000 100%); + color: var(--text); + font-family: var(--font-dm-sans), sans-serif; +} + +body::before { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + background: + radial-gradient(circle at top center, rgba(70, 108, 255, 0.08), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 10%); + opacity: 0.05; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +input[type="file"]::file-selector-button, +a[href] { + cursor: pointer; +} + +button:disabled, +input[type="file"]:disabled::file-selector-button { + cursor: default; + opacity: 0.82; +} + +.primary-button:disabled, +.button:disabled { + border-color: rgba(140, 179, 255, 0.2); + background: linear-gradient(180deg, rgba(46, 95, 236, 0.9), rgba(26, 71, 212, 0.9)); + color: rgba(239, 245, 255, 0.82); +} + +.secondary-button:disabled { + border-color: rgba(110, 146, 220, 0.18); + background: + linear-gradient(180deg, rgba(45, 72, 154, 0.24), rgba(24, 40, 90, 0.1)), + rgba(18, 28, 56, 0.94); + color: rgba(233, 240, 255, 0.76); +} + +code { + border: 1px solid rgba(182, 191, 223, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + padding: 0.15rem 0.45rem; + font-size: 0.88em; +} + +::selection { + background: rgba(48, 100, 255, 0.34); +} + +.app-shell { + position: relative; + display: flex; + flex-direction: column; + min-height: 100vh; + overflow: hidden; +} + +.bg-orb { + position: fixed; + border-radius: 999px; + filter: blur(80px); + pointer-events: none; + opacity: 0.24; +} + +.bg-orb-one { + top: -6rem; + left: -4rem; + width: 20rem; + height: 20rem; + background: rgba(48, 100, 255, 0.16); +} + +.bg-orb-two { + right: -4rem; + bottom: -6rem; + width: 18rem; + height: 18rem; + background: rgba(84, 132, 255, 0.1); +} + +.topbar, +.page-frame { + position: relative; + z-index: 1; + width: min(80vw, 1720px); + margin: 0 auto; +} + +.topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 0 0; +} + +.brand { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.brand-mark { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 1rem; + overflow: hidden; +} + +.brand-logo-image { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 1rem; +} + +.brand-title { + margin: 0; + font-family: var(--font-space-grotesk), sans-serif; + font-weight: 600; + letter-spacing: -0.04em; +} + +.brand-copy { + margin: 0.2rem 0 0; + color: var(--muted); + font-size: 0.92rem; +} + +.nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.nav-link { + border: 1px solid transparent; + border-radius: 999px; + padding: 0.72rem 1rem; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); + transition: border-color 180ms ease, color 180ms ease, transform 180ms ease; +} + +.nav-link:hover, +.nav-link-active { + border-color: var(--border); + color: var(--text); + transform: translateY(-1px); +} + +.landing-nav-button { + font: inherit; + cursor: pointer; +} + +.page-frame { + padding: 3.25rem 0 4rem; +} + +.hero { + max-width: 76rem; + padding: 1rem 0 2.2rem; +} + +.eyebrow, +.section-kicker, +.workspace-label, +.soft-panel-label { + margin: 0; + color: #98dde1; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.26em; + text-transform: uppercase; +} + +.hero h1, +.card h2, +.section-title, +.workspace-sidebar-title, +.workspace-role-title { + margin: 0.8rem 0 0; + font-family: var(--font-space-grotesk), sans-serif; + font-weight: 600; + letter-spacing: -0.045em; +} + +.hero h1 { + font-size: clamp(2.6rem, 5vw, 4.8rem); + line-height: 0.98; +} + +.hero-copy, +.section-copy, +.workspace-sidebar-copy, +.workspace-role-copy, +.workspace-results-copy, +.workspace-muted-copy { + margin: 1rem 0 0; + color: var(--muted); + font-size: 1.02rem; + line-height: 1.8; +} + +.workspace-next-step-note { + margin-top: 1rem; + border: 1px solid rgba(118, 149, 224, 0.18); + border-radius: 1rem; + background: + linear-gradient(180deg, rgba(35, 57, 122, 0.14), rgba(10, 17, 36, 0.1)), + rgba(8, 12, 22, 0.92); + padding: 0.95rem 1.05rem; + color: #d5def0; + line-height: 1.75; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + margin-top: 1.35rem; +} + +.landing-page-frame { + flex: 1 0 auto; + padding-top: 6.2rem; + padding-bottom: 1.35rem; +} + +.landing-hero { + max-width: none; + min-height: 27.5rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 2.75rem 0 1.25rem; + text-align: center; +} + +.landing-hero h1 { + max-width: 14ch; + font-size: clamp(3.8rem, 6vw, 6.5rem); + line-height: 0.93; + margin-bottom: 2rem; +} + +.landing-hero-copy { + max-width: 50rem; + font-size: 1.12rem; + line-height: 1.95; + margin-top: 0; +} + +.landing-auth-summary { + display: grid; + justify-items: center; + gap: 0.8rem; + margin-top: 1.2rem; +} + +.landing-auth-copy { + margin: 0; + max-width: 36rem; + color: #d2ddef; + line-height: 1.8; +} + +.landing-footer { + position: relative; + z-index: 1; + margin-top: auto; + border-top: 1px solid rgba(112, 141, 220, 0.14); +} + +.landing-footer-inner { + width: min(80vw, 1720px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto; + gap: 1.6rem; + padding: 0.95rem 0 0.72rem; +} + +.landing-footer-brand { + max-width: 32rem; +} + +.landing-footer-title, +.policy-title, +.policy-section h2 { + margin: 0; + font-family: var(--font-space-grotesk), sans-serif; + letter-spacing: -0.04em; +} + +.landing-footer-title { + font-size: 1.7rem; + font-weight: 600; +} + +.landing-footer-copy, +.landing-footer-credit, +.policy-intro, +.policy-section p { + color: #bcc9df; + line-height: 1.85; +} + +.landing-footer-copy { + margin: 0.75rem 0 0; + max-width: 28rem; +} + +.landing-footer-credit { + margin: 0.65rem 0 0; +} + +.landing-footer-links { + display: grid; + grid-template-columns: repeat(2, minmax(8rem, auto)); + gap: 1.4rem; +} + +.landing-footer-column { + display: grid; + align-content: start; + gap: 0.2rem; +} + +.landing-footer-heading { + margin: 0; + color: #f4f8ff; + font-weight: 600; + font-size: 1.08rem; +} + +.landing-footer-link { + color: #d6e0f4; + font-size: 1.04rem; + line-height: 1.55; + transition: color 180ms ease, transform 180ms ease; +} + +.landing-footer-link:hover { + color: #ffffff; + transform: translateX(1px); +} + +.landing-footer-link-placeholder { + color: #8695b1; +} + +.policy-page-frame { + padding-top: 4.4rem; + padding-bottom: 5rem; +} + +.policy-shell { + max-width: 91rem; + margin: 0 auto; + padding: 2.15rem 1.9rem 2.3rem; +} + +.policy-title { + margin-top: 0.7rem; + font-size: clamp(2.6rem, 4.5vw, 4.4rem); + font-weight: 600; + line-height: 0.98; +} + +.policy-intro { + margin: 1.2rem 0 0; + max-width: none; + font-size: 1.2rem; + line-height: 1.95; + text-align: justify; + text-justify: inter-word; +} + +.policy-effective { + margin: 0.7rem 0 0; + color: #93a7c7; + font-size: 0.95rem; +} + +.policy-sections { + display: grid; + gap: 1.55rem; + margin-top: 1.7rem; +} + +.policy-section { + padding: 0; +} + +.policy-section + .policy-section { + padding-top: 1.55rem; + border-top: 1px solid rgba(114, 145, 223, 0.1); +} + +.policy-section h2 { + font-size: 1.58rem; + font-weight: 600; + line-height: 1.16; +} + +.policy-section p { + margin: 0.8rem 0 0; + font-size: 1.28rem; + line-height: 1.92; + text-align: justify; + text-justify: inter-word; +} + +.policy-inline-link { + color: #dce8ff; + text-decoration: underline; + text-decoration-color: rgba(139, 179, 255, 0.5); + text-underline-offset: 0.15em; +} + +.button, +.primary-button, +.secondary-button, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + border-radius: 999px; + padding: 0.85rem 1.2rem; + font-weight: 600; + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + box-shadow 180ms ease, + opacity 180ms ease; +} + +.button, +.primary-button { + border: 1px solid rgba(166, 209, 255, 0.24); + background: linear-gradient(180deg, #2d63ff, #114be9); + color: white; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 10px 20px rgba(20, 75, 233, 0.22), + 0 0 0 1px rgba(56, 104, 255, 0.1); +} + +.button-secondary, +.secondary-button { + border: 1px solid rgba(120, 162, 255, 0.22); + background: + linear-gradient(180deg, rgba(38, 59, 122, 0.38), rgba(23, 35, 76, 0.18)), + rgba(18, 28, 56, 0.94); + color: white; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.028), + 0 10px 22px rgba(4, 10, 24, 0.16); +} + +.danger-button { + border: 1px solid rgba(255, 118, 138, 0.3); + background: + linear-gradient(180deg, rgba(120, 26, 48, 0.56), rgba(72, 13, 29, 0.28)), + rgba(56, 12, 23, 0.94); + color: #fff2f5; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 10px 22px rgba(44, 6, 16, 0.22); +} + +.button:hover, +.primary-button:hover, +.secondary-button:hover, +.danger-button:hover { + transform: translateY(-1px); +} + +.button:hover, +.primary-button:hover { + border-color: rgba(185, 221, 255, 0.32); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 14px 28px rgba(17, 75, 233, 0.24), + 0 0 12px rgba(43, 96, 255, 0.08); +} + +.secondary-button:hover { + border-color: rgba(158, 204, 255, 0.3); + background: + linear-gradient(180deg, rgba(52, 82, 180, 0.34), rgba(28, 45, 102, 0.14)), + rgba(20, 31, 60, 0.96); +} + +.danger-button:hover { + border-color: rgba(255, 149, 168, 0.42); + background: + linear-gradient(180deg, rgba(148, 32, 60, 0.62), rgba(86, 16, 34, 0.32)), + rgba(64, 13, 27, 0.96); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 14px 28px rgba(58, 8, 20, 0.28), + 0 0 12px rgba(255, 94, 129, 0.1); +} + +.button-spinner { + width: 0.95rem; + height: 0.95rem; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.28); + border-top-color: rgba(255, 255, 255, 0.98); + margin-right: 0.55rem; + animation: button-spin 720ms linear infinite; +} + +.content-stack { + display: grid; + gap: 1.2rem; +} + +.section-grid, +.tile-grid, +.stats-grid { + display: grid; + gap: 1rem; +} + +.card, +.surface-card, +.soft-panel, +.workspace-sidebar-shell, +.workspace-sidebar-card, +.metric-tile, +.job-result-card, +.workspace-section-card, +.notice-panel { + border: 1px solid rgba(128, 146, 198, 0.12); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.012), rgba(255, 255, 255, 0.003)), + rgba(7, 9, 16, 0.955); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.014), + 0 22px 52px rgba(0, 0, 0, 0.24); +} + +.card, +.surface-card { + border-radius: 1.65rem; + padding: 1.5rem; +} + +.metric-tile { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 0.15rem; + border-radius: 1.3rem; + padding: 1rem 1.05rem; + border: 1px solid rgba(108, 130, 192, 0.11); + background: + linear-gradient(180deg, rgba(34, 50, 108, 0.18), rgba(14, 21, 44, 0.06) 24%, rgba(0, 0, 0, 0) 56%), + radial-gradient(circle at 14% 0%, rgba(79, 116, 230, 0.08), transparent 18%), + linear-gradient(180deg, rgba(8, 12, 23, 0.992), rgba(5, 8, 16, 0.998)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.014), + 0 14px 30px rgba(2, 4, 10, 0.18); +} + +.card-highlight, +.surface-card-neutral { + background: + radial-gradient(circle at 50% -10%, rgba(60, 92, 194, 0.06), transparent 24%), + linear-gradient(135deg, rgba(40, 66, 150, 0.03), rgba(255, 255, 255, 0) 36%), + linear-gradient(180deg, rgba(6, 9, 17, 0.99), rgba(3, 5, 10, 0.999)); +} + +.card-header, +.section-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.card h2, +.section-title, +.workspace-sidebar-title, +.workspace-role-title { + font-size: 1.45rem; + line-height: 1.08; +} + +.tile, +.stat-block, +.list-row { + border: 1px solid rgba(146, 164, 206, 0.15); + border-radius: 1.2rem; + background: rgba(11, 14, 22, 0.88); +} + +.tile, +.stat-block { + padding: 1rem; +} + +.tile h3, +.row-title { + margin: 0; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1rem; + font-weight: 600; +} + +.tile p, +.list-row p, +.muted-copy { + margin: 0.65rem 0 0; + color: var(--muted); + line-height: 1.75; +} + +.stack-list { + display: grid; + gap: 0.85rem; +} + +.list-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.9rem; + padding: 1rem; +} + +.status-badge, +.status-chip, +.workspace-stage-pill, +.workspace-meta-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 0.42rem 0.75rem; + font-size: 0.78rem; + letter-spacing: 0.05em; +} + +.status-badge, +.status-chip, +.workspace-stage-pill { + border: 1px solid rgba(146, 164, 206, 0.2); + background: rgba(8, 11, 18, 0.9); + color: var(--text); + font-weight: 700; + text-transform: uppercase; +} + +.status-success, +.status-chip-live, +.workspace-stage-pill-live { + border-color: rgba(143, 225, 179, 0.26); + color: var(--success); +} + +.status-warning, +.status-chip-warning, +.workspace-stage-pill-ready { + border-color: rgba(255, 201, 141, 0.28); + color: var(--warning); +} + +.workspace-stage-pill-next { + border-color: rgba(157, 180, 255, 0.22); + color: #d7e4ff; +} + +.stat-block span, +.metric-tile span, +.workspace-sidebar-stat span { + display: block; + color: #94d7dc; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.stat-block strong, +.metric-tile strong, +.workspace-sidebar-stat strong { + display: block; + margin-top: 0.7rem; + color: white; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1rem; + font-weight: 600; + line-height: 1.4; +} + +.metric-tile small, +.workspace-sidebar-stat small { + display: block; + margin-top: 0.55rem; + color: #adb9d1; + font-size: 0.84rem; + line-height: 1.6; +} + +.workspace-shell { + position: relative; + z-index: 1; + width: 100%; +} + +.workspace-shell-inner { + width: min(80vw, 1720px); + margin: 0 auto; + padding: 1.25rem 0 3rem; +} + +.workspace-page { + position: relative; + isolation: isolate; + overflow: hidden; + min-height: 100vh; + background: linear-gradient( + 180deg, + rgba(6, 8, 14, 0.99) 0%, + rgba(3, 5, 10, 0.9) 14rem, + rgba(2, 4, 8, 0.04) 28rem, + rgba(0, 0, 0, 0) 38rem + ); +} + +.workspace-page::before { + content: ""; + pointer-events: none; + position: absolute; + left: 50%; + top: -12rem; + width: min(108rem, 136vw); + height: 34rem; + transform: translateX(-50%); + background: + radial-gradient( + ellipse at center, + rgba(18, 31, 84, 0.24) 0%, + rgba(10, 18, 46, 0.09) 22%, + rgba(6, 10, 20, 0.02) 50%, + transparent 74% + ); + filter: blur(50px) saturate(92%); + opacity: 0.28; + z-index: -2; +} + +.workspace-page::after { + content: ""; + pointer-events: none; + position: absolute; + inset: 0; + background: + radial-gradient( + ellipse at 50% 0, + rgba(18, 31, 74, 0.14) 0%, + rgba(10, 18, 40, 0.07) 24%, + rgba(5, 8, 19, 0.04) 52%, + transparent 72% + ), + linear-gradient( + 180deg, + rgba(3, 5, 12, 0) 0%, + rgba(0, 0, 0, 0.08) 50%, + rgba(0, 0, 0, 0.82) 88%, + rgba(0, 0, 0, 1) 100% + ); + z-index: -1; +} + +.workspace-layout { + position: relative; +} + +.workspace-drawer-toggle, +.workspace-sidebar-toggle { + display: inline-flex; + width: 2.8rem; + height: 2.8rem; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.26rem; + border-radius: 0.7rem; + border: 1px solid rgba(120, 148, 214, 0.12); + background: + linear-gradient(180deg, rgba(34, 50, 108, 0.16), rgba(12, 18, 34, 0.04)), + rgba(10, 14, 24, 0.97); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.035), + 0 12px 24px rgba(3, 5, 12, 0.2); + transition: + transform 180ms ease, + border-color 180ms ease, + background 180ms ease; +} + +.workspace-drawer-toggle:hover, +.workspace-sidebar-toggle:hover { + transform: translateY(-1px); + border-color: rgba(158, 204, 255, 0.18); +} + +.workspace-drawer-toggle { + position: fixed; + top: 1.25rem; + left: 1rem; + z-index: 45; +} + +.workspace-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 39; + border: 0; + background: rgba(2, 5, 12, 0.46); + backdrop-filter: blur(8px); +} + +.workspace-drawer-toggle span, +.workspace-sidebar-toggle span { + display: block; + width: 1.05rem; + height: 1.8px; + border-radius: 999px; + background: rgba(240, 245, 255, 0.96); +} + +.workspace-sidebar-close { + position: relative; +} + +.workspace-sidebar-close span { + position: absolute; +} + +.workspace-sidebar-close span:first-child { + transform: rotate(45deg); +} + +.workspace-sidebar-close span:last-child { + transform: rotate(-45deg); +} + +.workspace-sidebar { + position: fixed; + top: 1rem; + left: 1rem; + z-index: 40; + width: min(46rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + transform: translateX(-115%); + opacity: 0; + pointer-events: none; + transition: + transform 220ms ease, + opacity 180ms ease; +} + +.workspace-sidebar-open { + transform: translateX(0); + opacity: 1; + pointer-events: auto; +} + +.workspace-sidebar-shell { + height: calc(100vh - 2rem); + overflow-y: auto; + display: flex; + flex-direction: column; + border-radius: 1.75rem; + padding: 1rem; + border: 1px solid rgba(104, 128, 194, 0.08); + background: + linear-gradient(180deg, rgba(42, 60, 126, 0.16), rgba(20, 29, 63, 0.04) 16%, rgba(0, 0, 0, 0) 34%), + linear-gradient(180deg, rgba(9, 12, 22, 0.98), rgba(5, 8, 15, 0.99)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.018), + 0 22px 46px rgba(0, 0, 0, 0.26); +} + +.workspace-sidebar-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.workspace-brand-lockup { + display: flex; + align-items: center; + gap: 0.95rem; + margin-bottom: 0; +} + +.workspace-brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 1rem; + overflow: hidden; +} + +.workspace-brand-logo-image { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 1rem; +} + +.workspace-brand-title { + margin: 0; + color: #eef2ff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1.28rem; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.15; +} + +.workspace-brand-copy { + margin: 0.25rem 0 0; + color: #99a7c5; + font-size: 0.86rem; + line-height: 1.6; +} + +.workspace-sidebar-card { + border-radius: 1.45rem; + padding: 1rem; + border: 1px solid rgba(104, 128, 194, 0.08); + background: + linear-gradient(180deg, rgba(36, 52, 114, 0.14), rgba(18, 26, 54, 0.04) 18%, rgba(0, 0, 0, 0) 38%), + linear-gradient(180deg, rgba(9, 12, 21, 0.98), rgba(6, 9, 17, 0.995)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.016), + 0 14px 30px rgba(0, 0, 0, 0.18); +} + +.workspace-sidebar-card + .workspace-sidebar-card { + margin-top: 0.95rem; +} + +.workspace-auth-panel { + display: flex; + align-items: center; + gap: 0.9rem; + margin-top: 1rem; + border-radius: 1.15rem; + border: 1px solid rgba(120, 146, 208, 0.1); + background: + linear-gradient(180deg, rgba(32, 47, 102, 0.12), rgba(15, 22, 48, 0.03) 28%, rgba(0, 0, 0, 0) 56%), + rgba(8, 11, 19, 0.94); + padding: 0.95rem; +} + +.workspace-auth-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + border-radius: 999px; + background: linear-gradient(180deg, #f26b2a, #d94a0d); + color: white; + font-size: 0.95rem; + font-weight: 700; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.14), + 0 10px 20px rgba(217, 74, 13, 0.16); +} + +.workspace-auth-title { + margin: 0; + color: #f4f8ff; + font-weight: 600; +} + +.workspace-auth-copy { + margin: 0.35rem 0 0; + color: #b0bdd6; + font-size: 0.88rem; + line-height: 1.55; +} + +.workspace-sidebar-actions { + display: grid; + gap: 0.7rem; + margin-top: 1rem; +} + +.workspace-button-full { + width: 100%; +} + +.workspace-assistant-card { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.workspace-assistant-form { + display: grid; + gap: 0.7rem; + margin-top: 0.75rem; +} + +.workspace-assistant-textarea { + min-height: 4.5rem; + max-height: 7rem; + width: 100%; + border: 1px solid rgba(112, 138, 202, 0.12); + border-radius: 1.1rem; + background: + linear-gradient(180deg, rgba(28, 42, 92, 0.08), rgba(0, 0, 0, 0) 28%), + rgba(5, 8, 14, 0.88); + color: white; + padding: 0.9rem 0.95rem; + resize: vertical; + line-height: 1.65; + outline: none; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.workspace-assistant-thread { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 0.65rem; + border-radius: 1.1rem; + border: 1px solid rgba(112, 138, 202, 0.12); + background: + linear-gradient(180deg, rgba(28, 42, 92, 0.08), rgba(0, 0, 0, 0) 28%), + rgba(5, 8, 14, 0.88); + padding: 0.8rem; +} + +.workspace-assistant-textarea::placeholder { + color: #7f8aa3; +} + +.workspace-assistant-textarea:focus { + border-color: rgba(111, 160, 255, 0.5); + box-shadow: 0 0 0 3px rgba(48, 100, 255, 0.14); +} + +.workspace-sidebar-inline-metrics { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.9rem; +} + +.workspace-sidebar-stats, +.workspace-form-stack, +.workspace-section-stack, +.workspace-feature-list { + display: grid; + gap: 1rem; +} + +.workspace-sidebar-stat { + border-radius: 1rem; + padding: 0.9rem; + background: rgba(10, 13, 20, 0.88); +} + +.workspace-main { + display: grid; + gap: 1.35rem; + width: 100%; + min-width: 0; +} + +.workspace-main-topbar { + position: fixed; + top: 1.25rem; + right: 1rem; + z-index: 38; + display: flex; + justify-content: flex-end; + pointer-events: none; +} + +.workspace-main-topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.85rem; + pointer-events: auto; +} + +.workspace-account-menu { + position: relative; +} + +.workspace-account-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.9rem; + height: 2.9rem; + border-radius: 999px; + border: 1px solid rgba(120, 148, 214, 0.16); + background: + linear-gradient(180deg, rgba(34, 50, 108, 0.18), rgba(12, 18, 34, 0.04)), + rgba(10, 14, 24, 0.97); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 12px 24px rgba(3, 5, 12, 0.18); + cursor: pointer; + transition: + transform 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; +} + +.workspace-account-trigger:hover { + transform: translateY(-1px); + border-color: rgba(158, 204, 255, 0.22); +} + +.workspace-account-trigger:focus-visible { + outline: none; + box-shadow: + 0 0 0 3px rgba(56, 104, 255, 0.16), + 0 12px 24px rgba(3, 5, 12, 0.18); +} + +.workspace-account-trigger-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2rem; + height: 2.2rem; + border-radius: 999px; + background: linear-gradient(180deg, #4a78ff, #3064ff); + color: #fff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 0.95rem; + font-weight: 700; + box-shadow: 0 10px 18px rgba(48, 100, 255, 0.18); +} + +.workspace-account-popover { + position: absolute; + top: calc(100% + 0.75rem); + right: 0; + z-index: 18; + width: min(24rem, calc(100vw - 2rem)); + padding: 1rem; + border-radius: 1.4rem; + border: 1px solid rgba(104, 128, 194, 0.1); + background: + linear-gradient(180deg, rgba(36, 52, 114, 0.14), rgba(18, 26, 54, 0.04) 18%, rgba(0, 0, 0, 0) 38%), + linear-gradient(180deg, rgba(9, 12, 21, 0.985), rgba(6, 9, 17, 0.995)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.018), + 0 24px 48px rgba(0, 0, 0, 0.3); +} + +.workspace-auth-panel-inline { + margin-top: 0; +} + +.workspace-account-metrics { + margin-top: 0.85rem; +} + +.workspace-account-actions { + margin-top: 0.95rem; +} + +.job-hero-panel { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1.9rem 1.65rem 1.45rem; + min-height: 27rem; + border-color: rgba(100, 126, 194, 0.09); + background: + linear-gradient(180deg, rgba(34, 50, 108, 0.22) 0%, rgba(18, 28, 60, 0.08) 12%, rgba(0, 0, 0, 0) 28%), + linear-gradient(90deg, rgba(16, 24, 54, 0.1), rgba(9, 14, 29, 0) 24%, rgba(11, 18, 37, 0.07) 100%), + radial-gradient(circle at 50% -36%, rgba(88, 121, 228, 0.035), transparent 10%), + radial-gradient(circle at 16% 3%, rgba(46, 72, 174, 0.08), transparent 12%), + radial-gradient(circle at 84% -4%, rgba(24, 40, 102, 0.05), transparent 14%), + linear-gradient(180deg, rgba(10, 14, 28, 0.995), rgba(4, 7, 14, 1)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.012), + 0 18px 44px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(16, 24, 54, 0.14); +} + +.job-hero-panel::before { + content: ""; + pointer-events: none; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(132, 163, 246, 0.012), transparent 8%), + radial-gradient(circle at 50% 0%, rgba(102, 132, 228, 0.022), transparent 12%), + radial-gradient(circle at 12% 10%, rgba(62, 96, 204, 0.02), transparent 10%); + opacity: 0.4; +} + +.job-hero-panel::after { + content: ""; + pointer-events: none; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 50% 0%, rgba(4, 7, 14, 0.12), transparent 18%), + linear-gradient(180deg, rgba(6, 10, 18, 0.03), rgba(0, 0, 0, 0.08) 68%, rgba(0, 0, 0, 0.2) 100%), + linear-gradient(90deg, rgba(4, 7, 14, 0.02), rgba(4, 7, 14, 0) 30%, rgba(4, 7, 14, 0.06) 100%); + mix-blend-mode: normal; +} + +.job-hero-grid, +.workspace-summary-grid, +.workspace-section-grid, +.workspace-review-columns, +.workspace-lane-grid, +.workspace-hero-metrics { + display: grid; + gap: 1.15rem; +} + +.workspace-summary-grid, +.workspace-review-columns { + margin-top: 1.2rem; +} + +.workspace-summary-grid + .workspace-review-columns, +.workspace-review-columns + .workspace-section-card, +.workspace-summary-grid + .workspace-section-card, +.workspace-review-columns + .workspace-section-stack { + margin-top: 1.25rem; +} + +.job-hero-grid { + align-content: center; + min-height: 100%; +} + +.job-hero-grid > div { + position: relative; + padding-top: 2.2rem; +} + +.job-hero-grid .eyebrow { + position: absolute; + top: 0.2rem; + left: 0; + margin: 0; + font-size: 0.8rem; + letter-spacing: 0.29em; +} + +.workspace-hero-title { + margin-top: 1.2rem; + max-width: none; + font-size: clamp(3.7rem, 5.15vw, 5.95rem); + line-height: 0.94; + background: + linear-gradient(180deg, rgba(232, 243, 255, 0.16), rgba(232, 243, 255, 0) 38%), + linear-gradient(180deg, #96a2aa 0%, #707a80 48%, #525a5f 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + letter-spacing: -0.058em; + filter: + drop-shadow(0 0 1px rgba(232, 243, 255, 0.2)) + drop-shadow(0 0 18px rgba(154, 196, 255, 0.16)) + drop-shadow(0 18px 34px rgba(0, 0, 0, 0.38)); + white-space: normal; + text-wrap: balance; +} + +.workspace-hero-copy { + max-width: 62rem; + margin: 1.45rem 0 0; + color: #b4c6e3; + font-size: 1.06rem; + line-height: 1.9; +} + +.workspace-hero-metrics-below { + margin-top: 1.35rem; +} + +.workspace-hero-metric-tile { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 0.45rem; + min-height: 8.5rem; + padding: 1.1rem 1.15rem; + border-radius: 1.35rem; + border: 1px solid rgba(112, 136, 198, 0.075); + background: + linear-gradient(180deg, rgba(42, 60, 126, 0.28), rgba(18, 26, 56, 0.09) 22%, rgba(0, 0, 0, 0) 52%), + radial-gradient(circle at 12% 0%, rgba(74, 111, 226, 0.08), transparent 18%), + linear-gradient(180deg, rgba(8, 12, 23, 0.985), rgba(6, 10, 18, 0.997)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.01), + 0 14px 30px rgba(3, 5, 12, 0.16); +} + +.workspace-hero-metric-head { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.workspace-hero-metric-icon { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.35rem; + block-size: 1.35rem; + border-radius: 999px; + border: 1px solid rgba(126, 210, 223, 0.18); + background: + linear-gradient(180deg, rgba(91, 154, 223, 0.16), rgba(47, 79, 141, 0.08)), + rgba(10, 15, 27, 0.94); + color: #d7fbff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0; + flex: 0 0 auto; +} + +.workspace-hero-metric-tile span { + color: #96dde2; + font-size: 0.68rem; + letter-spacing: 0.2em; +} + +.workspace-hero-metric-tile strong { + margin-top: 0; + font-size: 1.18rem; + line-height: 1.25; +} + +.workspace-hero-metric-tile small { + margin-top: 0; + color: #bccae0; + font-size: 0.92rem; + line-height: 1.55; +} + +.workspace-main-nav { + display: grid; + gap: 1rem; + padding: 1.2rem; +} + +.workspace-main-nav-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.workspace-main-nav-title { + margin: 0.75rem 0 0; + color: #eef2ff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: clamp(1.45rem, 2vw, 2rem); + font-weight: 600; + letter-spacing: -0.04em; +} + +.workspace-main-nav-copy, +.workspace-main-tab-note { + margin: 0.6rem 0 0; + color: #bec9de; + line-height: 1.65; +} + +.workspace-main-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.workspace-main-tab { + flex: 1 1 14rem; + min-width: min(100%, 14rem); + border: 1px solid rgba(146, 164, 206, 0.14); + border-radius: 1.2rem; + background: + linear-gradient(180deg, rgba(84, 132, 255, 0.035), rgba(84, 132, 255, 0.01)), + rgba(8, 10, 16, 0.92); + padding: 0.95rem 1rem; + color: #eef3ff; + text-align: left; + transition: + transform 180ms ease, + border-color 180ms ease, + background 180ms ease, + box-shadow 180ms ease; +} + +.workspace-main-tab:hover { + transform: translateY(-1px); + border-color: rgba(155, 193, 255, 0.24); +} + +.workspace-main-tab-active { + border-color: rgba(111, 164, 255, 0.34); + background: + linear-gradient(180deg, rgba(31, 52, 122, 0.46), rgba(16, 27, 63, 0.2) 22%, rgba(0, 0, 0, 0) 62%), + radial-gradient(circle at 18% 0%, rgba(82, 123, 248, 0.12), transparent 22%), + radial-gradient(circle at 50% -28%, rgba(88, 132, 255, 0.09), transparent 38%), + rgba(5, 9, 19, 0.996); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + inset 0 18px 28px rgba(57, 88, 176, 0.07), + 0 20px 36px rgba(4, 8, 18, 0.42), + 0 0 0 1px rgba(48, 88, 188, 0.18); +} + +.workspace-main-tab-active .workspace-main-tab-label { + color: #f7fbff; +} + +.workspace-main-tab-active .workspace-main-tab-status { + border-color: rgba(119, 170, 255, 0.2); + background: + linear-gradient(180deg, rgba(22, 34, 76, 0.78), rgba(9, 14, 31, 0.92)), + rgba(8, 12, 22, 0.96); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 8px 18px rgba(6, 10, 24, 0.28); +} + +.workspace-main-tab-top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.workspace-main-tab-label { + color: #f4f8ff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.workspace-main-tab-status { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(146, 164, 206, 0.16); + background: rgba(8, 11, 18, 0.86); + color: #e5eeff; + padding: 0.38rem 0.72rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace-main-tab-status-live { + border-color: rgba(143, 225, 179, 0.28); + color: #b9f3d1; +} + +.workspace-main-tab-status-ready { + border-color: rgba(149, 181, 255, 0.24); + color: #d6e4ff; +} + +.workspace-main-tab-status-idle { + border-color: rgba(255, 255, 255, 0.1); + color: #aebbd8; +} + +.workspace-button { + min-height: 2.85rem; + padding-inline: 1.15rem; +} + +.workspace-button-small { + min-height: 2.5rem; + padding-inline: 0.95rem; + font-size: 0.88rem; +} + +.workspace-button-small[href] { + text-decoration: none; +} + +.workspace-button-disabled { + pointer-events: none; +} + +.workspace-field-grid { + display: grid; + gap: 0.95rem; +} + +.workspace-field-grid-search { + gap: 1rem; +} + +.workspace-field, +.workspace-select-field { + display: grid; + gap: 0.55rem; +} + +.workspace-select-field-inline { + display: inline-flex; + align-items: center; + gap: 0.7rem; +} + +.workspace-label-inline { + margin: 0; + white-space: nowrap; +} + +.workspace-field-wide { + min-width: 0; + flex: 1; +} + +.workspace-input, +.workspace-select, +.workspace-textarea { + width: 100%; + border: 1px solid rgba(146, 164, 206, 0.16); + border-radius: 1.15rem; + background: rgba(4, 7, 13, 0.96); + color: white; + padding: 0.92rem 1rem; + outline: none; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.workspace-input::placeholder, +.workspace-textarea::placeholder { + color: #7e8cab; +} + +.workspace-input:focus, +.workspace-select:focus, +.workspace-textarea:focus { + border-color: rgba(111, 160, 255, 0.5); + box-shadow: 0 0 0 3px rgba(48, 100, 255, 0.14); +} + +.workspace-textarea { + min-height: 17rem; + resize: vertical; + line-height: 1.75; +} + +.workspace-inline-controls, +.workspace-inline-import, +.workspace-role-actions, +.workspace-results-head, +.job-result-head, +.job-result-badges, +.workspace-chip-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.workspace-inline-controls, +.workspace-inline-import, +.workspace-results-head, +.job-result-head { + justify-content: space-between; +} + +.section-head-actions, +.workspace-results-head-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.65rem; +} + +.workspace-search-toolbar, +.workspace-search-filters { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.workspace-search-toolbar { + align-items: center; + justify-content: space-between; +} + +.workspace-search-filters { + align-items: center; + flex: 1 1 28rem; + width: 100%; +} + +.workspace-search-filter-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.25rem; +} + +.workspace-jd-stack, +.workspace-jd-load-panel { + display: grid; + gap: 1rem; +} + +.workspace-jd-stack { + margin-top: 1rem; +} + +.workspace-search-toolbar .workspace-button, +.workspace-inline-import-split .workspace-button { + flex: 0 0 auto; +} + +.workspace-action-button { + min-width: 11rem; + justify-content: center; +} + +.workspace-inline-import-split .workspace-action-button { + min-width: 12rem; +} + +.workspace-inline-import-split { + align-items: end; + gap: 1rem; +} + +.workspace-inline-import-split + .workspace-results-head { + margin-top: 0.95rem; +} + +.notice-panel + .workspace-results-head { + margin-top: 1.1rem; +} + +.workspace-toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + color: #e0e9ff; + font-size: 0.95rem; +} + +.workspace-toggle input { + width: 1rem; + height: 1rem; + accent-color: var(--accent); +} + +.workspace-select { + min-width: 11rem; +} + +.workspace-select-field-inline .workspace-select { + min-width: 10rem; +} + +.workspace-results-list { + display: grid; + gap: 0.9rem; + margin-top: 1rem; +} + +.workspace-saved-jobs-list { + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + gap: 1rem; +} + +.workspace-saved-jobs-panel { + display: grid; + gap: 1rem; + margin-top: 1.35rem; + padding-top: 1.2rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.job-result-card, +.workspace-section-card, +.notice-panel, +.soft-panel { + border-radius: 1.25rem; + padding: 1rem; +} + +.job-result-card, +.workspace-section-card, +.soft-panel { + border: 1px solid rgba(108, 130, 192, 0.11); + background: + linear-gradient(180deg, rgba(36, 53, 114, 0.16), rgba(15, 22, 46, 0.05) 22%, rgba(0, 0, 0, 0) 52%), + radial-gradient(circle at 12% 0%, rgba(71, 107, 221, 0.065), transparent 18%), + linear-gradient(180deg, rgba(8, 12, 23, 0.992), rgba(5, 8, 16, 0.998)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.012), + 0 18px 36px rgba(2, 4, 10, 0.18); +} + +.job-result-card-active { + border-color: rgba(138, 200, 255, 0.28); + background: + linear-gradient(180deg, rgba(54, 82, 176, 0.24), rgba(18, 28, 60, 0.09) 22%, rgba(0, 0, 0, 0) 54%), + radial-gradient(circle at top left, rgba(78, 120, 236, 0.18), transparent 34%), + linear-gradient(180deg, rgba(10, 15, 29, 0.995), rgba(6, 10, 19, 0.998)); +} + +.workspace-saved-job-card { + display: grid; + grid-template-rows: auto auto auto 1fr auto; + gap: 0.85rem; + min-height: 100%; + border-color: rgba(138, 152, 188, 0.18); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.014), + 0 16px 34px rgba(0, 0, 0, 0.18); +} + +.workspace-result-tile { + background: + linear-gradient(180deg, rgba(43, 62, 132, 0.22), rgba(16, 23, 48, 0.07) 24%, rgba(0, 0, 0, 0) 54%), + radial-gradient(circle at 14% 0%, rgba(76, 111, 226, 0.08), transparent 18%), + linear-gradient(180deg, rgba(8, 12, 23, 0.992), rgba(5, 8, 16, 0.998)); +} + +.workspace-saved-job-card .job-result-head { + align-items: flex-start; +} + +.workspace-saved-job-card .job-result-company, +.workspace-saved-job-card .job-result-summary { + margin-top: 0; +} + +.workspace-saved-job-card .job-result-summary { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + min-height: 5.1rem; +} + +.workspace-saved-job-card .job-result-actions { + margin-top: 0.15rem; +} + +.workspace-result-tile .job-result-actions { + align-items: center; + margin-top: auto; + padding-top: 0.35rem; +} + +.workspace-result-tile .job-result-actions .workspace-button-small { + min-height: 2.95rem; + border-radius: 1.35rem; + padding-inline: 1.08rem; +} + +.job-result-head h3, +.workspace-section-card h3 { + margin: 0; + color: #f3f7ff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1.03rem; + font-weight: 600; +} + +.job-result-company, +.job-result-summary, +.workspace-empty-state { + margin: 0.4rem 0 0; + color: #aab7d3; + line-height: 1.7; +} + +.job-result-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.workspace-empty-state { + border: 1px dashed rgba(146, 164, 206, 0.18); + border-radius: 1.25rem; + background: + linear-gradient(180deg, rgba(30, 44, 94, 0.08), rgba(0, 0, 0, 0) 40%), + rgba(9, 12, 20, 0.9); + padding: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.workspace-empty-state::before { + content: "+"; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.5rem; + block-size: 1.5rem; + border-radius: 999px; + border: 1px dashed rgba(151, 221, 226, 0.28); + color: #9ce1e5; + font-size: 0.95rem; + font-weight: 700; + flex: 0 0 auto; +} + +.workspace-meta-chip { + border: 1px solid rgba(146, 164, 206, 0.16); + background: + linear-gradient(180deg, rgba(78, 118, 232, 0.08), rgba(78, 118, 232, 0.02)), + rgba(9, 12, 20, 0.92); + color: #dfe7ff; + padding: 0.38rem 0.82rem; + font-size: 0.8rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.notice-panel { + margin-top: 1rem; + font-size: 0.95rem; + line-height: 1.65; + border: 1px solid rgba(108, 130, 192, 0.12); + background: + linear-gradient(180deg, rgba(30, 44, 94, 0.08), rgba(0, 0, 0, 0) 42%), + rgba(8, 12, 21, 0.94); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.012), + 0 14px 28px rgba(2, 4, 10, 0.14); +} + +.notice-success { + border-color: rgba(127, 224, 176, 0.28); + color: #e3fff0; +} + +.notice-warning { + border-color: rgba(255, 203, 148, 0.28); + color: #fff0dd; +} + +.notice-info { + border-color: rgba(149, 181, 255, 0.26); + color: #e5efff; +} + +.workspace-progress-card { + margin-top: 1rem; + border: 1px solid rgba(166, 179, 229, 0.16); + border-radius: 1.35rem; + padding: 1rem; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)), + rgba(10, 11, 17, 0.88); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 18px 44px rgba(0, 0, 0, 0.18); +} + +.workspace-progress-tone-crew { + border-color: rgba(72, 110, 232, 0.24); + background: + radial-gradient(circle at top left, rgba(64, 116, 255, 0.12), transparent 32%), + linear-gradient(135deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)), + rgba(10, 11, 17, 0.88); +} + +.workspace-progress-tone-backup { + border-color: rgba(151, 162, 184, 0.22); +} + +.workspace-progress-tone-matchmaker { + border-color: rgba(71, 127, 255, 0.24); +} + +.workspace-progress-tone-forge { + border-color: rgba(249, 115, 22, 0.24); +} + +.workspace-progress-tone-navigator { + border-color: rgba(14, 165, 233, 0.24); +} + +.workspace-progress-tone-gatekeeper { + border-color: rgba(245, 158, 11, 0.22); +} + +.workspace-progress-tone-builder { + border-color: rgba(59, 130, 246, 0.24); +} + +.workspace-progress-tone-coverletter { + border-color: rgba(139, 92, 246, 0.24); +} + +.workspace-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.workspace-progress-tag, +.workspace-progress-percent { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #eef4ff; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + padding: 0.38rem 0.72rem; + text-transform: uppercase; +} + +.workspace-progress-percent { + color: #cddcff; +} + +.workspace-progress-detail { + margin: 0.85rem 0 0; + color: #eef4ff; + font-family: var(--font-space-grotesk), sans-serif; + font-size: 1rem; + line-height: 1.55; +} + +.workspace-progress-bar { + margin-top: 0.95rem; + width: 100%; + height: 0.55rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.workspace-progress-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgba(93, 134, 255, 0.95), rgba(129, 185, 255, 0.95)); + transition: width 280ms ease; +} + +.workspace-progress-stage-list { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.workspace-progress-stage { + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); + padding: 0.8rem 0.9rem; +} + +.workspace-progress-stage-live { + border-color: rgba(143, 225, 179, 0.28); + background: + linear-gradient(135deg, rgba(94, 234, 212, 0.07), rgba(255, 255, 255, 0.015)), + rgba(255, 255, 255, 0.03); +} + +.workspace-progress-stage-ready { + border-color: rgba(255, 201, 141, 0.22); +} + +.workspace-progress-stage-title { + display: block; + color: #f3f7ff; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace-progress-stage small { + display: block; + margin-top: 0.45rem; + color: #aebbd8; + font-size: 0.87rem; + line-height: 1.55; +} + +.workspace-progress-note { + margin-top: 0.9rem; +} + +.workspace-role-panel { + display: grid; + gap: 1rem; + margin-top: 1rem; + border-radius: 1.35rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); + padding: 1rem; +} + +.workspace-role-title { + margin-top: 0.6rem; + font-size: 1.25rem; +} + +.workspace-summary-grid-tight { + margin-top: 1rem; +} + +.workspace-status-tile { + min-height: 9.25rem; + justify-content: center; + background: + linear-gradient(180deg, rgba(43, 62, 132, 0.2), rgba(16, 23, 48, 0.06) 24%, rgba(0, 0, 0, 0) 54%), + radial-gradient(circle at 14% 0%, rgba(76, 111, 226, 0.08), transparent 18%), + linear-gradient(180deg, rgba(8, 12, 23, 0.992), rgba(5, 8, 16, 0.998)); +} + +.workspace-status-tile strong { + font-size: 1.08rem; +} + +.workspace-status-tile small { + max-width: 32ch; +} + +.workspace-section-card ul, +.workspace-feature-list { + margin: 0.85rem 0 0; + padding-left: 1.1rem; +} + +.workspace-section-card li, +.workspace-feature-list li { + color: #d6def1; + line-height: 1.75; +} + +.workspace-jd-sections { + margin-top: 1.45rem; + gap: 1.2rem; +} + +.workspace-jd-section-card { + padding: 1.2rem 1.3rem 1.35rem; +} + +.workspace-jd-section-card h3 { + font-size: 1.16rem; + line-height: 1.2; +} + +.workspace-jd-paragraphs { + max-width: none; + margin-top: 1rem; + display: grid; + gap: 0.95rem; +} + +.workspace-jd-paragraphs p { + margin: 0; + font-size: 0.98rem; + line-height: 1.9; + color: #d6def1; +} + +.workspace-chat-history, +.workspace-run-actions, +.workspace-tab-row, +.workspace-uploader { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.workspace-chat-history { + display: grid; + gap: 0.8rem; + margin-top: 0; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + width: 100%; + padding-right: 0; +} + +.workspace-chat-turn { + display: grid; + gap: 0.55rem; + width: 100%; +} + +.workspace-chat-bubble { + border-radius: 1.15rem; + padding: 0.2rem 0; + line-height: 1.7; + width: 100%; + word-break: break-word; +} + +.workspace-chat-user { + justify-self: end; + max-width: 100%; + border: 0; + background: transparent; + color: #bcd0ff; + font-weight: 600; +} + +.workspace-chat-assistant { + border: 0; + background: transparent; + color: #d7e3fb; +} + +/* Streaming-turn affordances — Item 3 (assistant streaming via SSE). + The cursor is purely visual and runs while `isStreaming === true`. + The thinking label fills the gap between the POST and the first + delta event so the bubble doesn't render as empty. */ +.workspace-chat-cursor { + display: inline-block; + margin-left: 0.15rem; + color: #7ea8ff; + animation: workspace-chat-cursor-blink 1s steps(2, end) infinite; +} + +@keyframes workspace-chat-cursor-blink { + to { + visibility: hidden; + } +} + +.workspace-chat-thinking { + color: rgba(215, 227, 251, 0.55); + font-style: italic; +} + +.workspace-chat-error { + color: #f3a8a8; +} + +.workspace-chat-followups { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.85rem; +} + +.workspace-chat-followup { + border: 1px solid rgba(126, 168, 255, 0.18); + border-radius: 999px; + background: + linear-gradient(180deg, rgba(84, 132, 255, 0.08), rgba(84, 132, 255, 0.03)), + rgba(255, 255, 255, 0.02); + color: #e5eeff; + padding: 0.55rem 0.9rem; + font-size: 0.8rem; + line-height: 1.4; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.workspace-chat-followup:hover { + transform: translateY(-1px); + border-color: rgba(164, 197, 255, 0.26); + background: + linear-gradient(180deg, rgba(84, 132, 255, 0.12), rgba(84, 132, 255, 0.05)), + rgba(255, 255, 255, 0.025); +} + +.workspace-empty-state-compact { + margin-top: 0; + padding: 0.9rem; + font-size: 0.92rem; +} + +.workspace-builder-answer { + min-height: 10.5rem; +} + +.workspace-builder-stack { + display: grid; + gap: 1.25rem; +} + +.workspace-builder-compact-textarea { + min-height: 8rem; +} + +.workspace-builder-chip-active { + border-color: rgba(133, 185, 255, 0.28); + background: linear-gradient(180deg, rgba(74, 120, 255, 0.2), rgba(48, 100, 255, 0.08)); + color: #f5f8ff; +} + +.workspace-builder-preview-card { + margin-top: 1.25rem; +} + +.workspace-builder-collapsed-copy { + margin-top: 0.8rem; +} + +.workspace-builder-edit-grid { + margin-top: 1.25rem; +} + +.workspace-builder-field-wide { + grid-column: 1 / -1; +} + +.workspace-builder-preview { + margin: 0.9rem 0 0; + padding: 1rem; + border-radius: 1rem; + border: 1px solid rgba(146, 164, 206, 0.14); + background: rgba(4, 7, 13, 0.92); + color: #dfe7ff; + font-size: 0.94rem; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} + +.workspace-assistant-followup-panel { + margin-top: 0.9rem; + width: 100%; + max-height: 20%; + overflow-y: auto; +} + +.workspace-uploader, +.workspace-run-actions, +.workspace-tab-row { + margin-top: 1rem; + align-items: center; +} + +.workspace-action-end { + margin-left: auto; +} + +.workspace-uploader { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.025); + padding: 0.85rem; +} + +.workspace-upload-trigger { + cursor: pointer; +} + +.workspace-hidden-input { + display: none; +} + +.workspace-file-name { + min-width: 0; + flex: 1; + color: #aebbd8; + font-size: 0.92rem; + line-height: 1.55; +} + +.workspace-file-status { + color: #dbe7ff; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; +} + +.workspace-run-actions { + justify-content: flex-start; +} + +.inspector-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.65rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.025); + color: #d3dff9; + padding: 0.65rem 1rem; + font-size: 0.88rem; + font-weight: 600; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +.inspector-tab:hover { + transform: translateY(-1px); + border-color: rgba(166, 201, 255, 0.22); +} + +.inspector-tab-active { + border-color: rgba(133, 185, 255, 0.28); + background: linear-gradient(180deg, rgba(74, 120, 255, 0.2), rgba(48, 100, 255, 0.08)); + color: #f5f8ff; +} + +.workspace-artifact-panel { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.workspace-artifact-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.workspace-artifact-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + +.workspace-artifact-preview-frame { + width: 100%; + min-height: 40rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.02); +} + +.workspace-feature-list-compact { + margin-top: 0.7rem; +} + +.workspace-feature-list-compact li + li { + margin-top: 0.2rem; +} + +@keyframes button-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1120px) { + .landing-hero { + min-height: 24rem; + padding-bottom: 1.4rem; + } + + .landing-footer-inner { + grid-template-columns: 1fr; + gap: 2rem; + } +} + +@media (max-width: 840px) { + .landing-page-frame { + padding-top: 3.4rem; + padding-bottom: 1rem; + } + + .landing-hero { + min-height: auto; + padding: 1.3rem 0 1.35rem; + } + + .landing-footer-inner, + .policy-page-frame { + width: min(100%, calc(100% - 1.25rem)); + } + + .landing-footer-links { + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + } + + .policy-shell { + padding: 1.55rem 1.2rem 1.7rem; + } +} + +@media (max-width: 720px) { + .landing-hero h1 { + max-width: 10ch; + } + + .landing-footer-links { + grid-template-columns: 1fr; + } +} + +@media (min-width: 860px) { + .section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .tile-grid, + .stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 980px) { + .job-hero-grid { + grid-template-columns: minmax(0, 1fr); + align-content: center; + } + + .workspace-sidebar { + width: min(46rem, calc(100vw - 2rem)); + } + + .workspace-hero-metrics, + .workspace-summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .workspace-summary-grid-tight { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .workspace-section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workspace-lane-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workspace-review-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workspace-saved-jobs-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workspace-field-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workspace-search-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + column-gap: 1rem; + } + + .workspace-search-filters { + grid-column: 2; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 1.6rem; + width: auto; + justify-content: flex-end; + } + + .workspace-search-filter-group { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 1.6rem; + } + + .workspace-search-filters .workspace-toggle { + justify-self: auto; + } + + .workspace-search-filters .workspace-select-field-inline { + justify-self: auto; + transform: none; + } + + .workspace-search-toolbar .workspace-action-button, + .workspace-inline-import-split .workspace-action-button { + min-width: 12.75rem; + inline-size: 12.75rem; + justify-self: end; + } + + .workspace-inline-import-split { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + column-gap: 1rem; + } +} + +@media (min-width: 980px) and (max-width: 1279px) { + .workspace-sidebar { + width: min(34rem, 82vw); + } +} + +@media (max-width: 979px) { + .topbar, + .page-frame { + width: min(100%, calc(100% - 1.25rem)); + } + + .workspace-shell-inner { + width: min(100%, calc(100% - 1.25rem)); + padding: 1rem 0 2.5rem; + } + + .workspace-main-topbar-actions { + width: 100%; + justify-content: flex-end; + } + + .workspace-account-popover { + width: min(22rem, calc(100vw - 1.75rem)); + } + + .workspace-drawer-toggle { + top: 0.9rem; + left: 0.9rem; + } + + .workspace-sidebar { + top: 0; + left: 0; + width: min(26rem, calc(100vw - 1rem)); + max-height: 100vh; + padding: 0.5rem 0 0.5rem 0.5rem; + } + + .workspace-sidebar-shell { + height: calc(100vh - 1rem); + } +} + +@media (max-width: 639px) { + .workspace-hero-title { + font-size: clamp(2.55rem, 11vw, 3.5rem); + line-height: 0.98; + } + + .workspace-label { + font-size: 0.68rem; + letter-spacing: 0.17em; + } + + .workspace-main-tab-note { + margin-top: 0.5rem; + font-size: 0.93rem; + line-height: 1.5; + } + + .workspace-main-tabs { + gap: 0.65rem; + } + + .workspace-main-tab { + flex-basis: 100%; + min-width: 0; + padding: 0.9rem 0.9rem 0.95rem; + } + + .workspace-sidebar { + width: calc(100vw - 0.75rem); + padding: 0.375rem 0 0.375rem 0.375rem; + } + + .workspace-main-topbar { + top: 0.9rem; + right: 0.9rem; + left: auto; + justify-content: stretch; + } + + .workspace-main-topbar-actions { + width: 100%; + justify-content: flex-end; + gap: 0.75rem; + } + + .workspace-account-popover { + right: 0; + width: min(21rem, calc(100vw - 1rem)); + } + + .workspace-uploader, + .workspace-search-toolbar, + .workspace-search-filters, + .workspace-search-filter-group, + .workspace-inline-import-split, + .workspace-run-actions { + display: grid; + gap: 0.85rem; + } + + .workspace-section-card, + .notice-panel, + .soft-panel { + padding: 0.9rem; + border-radius: 1.1rem; + } + + .section-head { + gap: 0.75rem; + } + + .section-head-actions, + .workspace-results-head-actions { + width: 100%; + justify-content: flex-start; + gap: 0.55rem; + } + + .status-chip, + .workspace-stage-pill, + .workspace-meta-chip, + .workspace-main-tab-status { + padding: 0.42rem 0.68rem; + font-size: 0.65rem; + letter-spacing: 0.11em; + } + + .workspace-summary-grid, + .workspace-lane-grid, + .workspace-builder-stack, + .workspace-form-stack, + .workspace-section-stack { + gap: 0.95rem; + } + + .workspace-button { + min-height: 3rem; + padding-inline: 1rem; + font-size: 0.95rem; + } + + .workspace-button-small { + min-height: 2.75rem; + font-size: 0.84rem; + } + + .workspace-search-toolbar, + .workspace-search-filters, + .workspace-search-filter-group, + .workspace-inline-import-split, + .workspace-run-actions { + justify-items: stretch; + } + + .workspace-file-status { + white-space: normal; + } + + .workspace-action-end { + margin-left: 0; + } + + .workspace-search-toolbar .workspace-action-button, + .workspace-inline-import-split .workspace-action-button, + .workspace-run-actions .workspace-button { + width: 100%; + min-width: 0; + } + + .workspace-toggle, + .workspace-select-field-inline, + .workspace-select, + .workspace-select-field-inline .workspace-select { + width: 100%; + min-width: 0; + } + + .workspace-input, + .workspace-select, + .workspace-textarea { + border-radius: 1rem; + font-size: 0.96rem; + } + + .workspace-textarea { + min-height: 12rem; + line-height: 1.65; + } + + .workspace-chip-grid { + gap: 0.6rem; + } + + .metric-tile { + border-radius: 1.15rem; + padding: 0.9rem; + } + + .metric-tile span, + .workspace-sidebar-stat span { + font-size: 0.66rem; + letter-spacing: 0.16em; + } + + .metric-tile strong, + .workspace-status-tile strong { + margin-top: 0.55rem; + font-size: 1rem; + line-height: 1.3; + } + + .metric-tile small, + .workspace-status-tile small { + margin-top: 0.42rem; + font-size: 0.82rem; + line-height: 1.55; + } + + .workspace-section-card h3 { + font-size: 0.98rem; + line-height: 1.35; + } + + .workspace-tab-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + } + + .inspector-tab { + width: 100%; + min-width: 0; + padding-inline: 0.85rem; + } + + .workspace-saved-jobs-list { + grid-template-columns: 1fr; + } + + .workspace-artifact-head, + .workspace-artifact-actions { + display: grid; + justify-content: stretch; + } + + .workspace-artifact-actions { + grid-template-columns: 1fr; + } + + .workspace-artifact-actions .workspace-button { + width: 100%; + min-width: 0; + } + + .workspace-artifact-preview-frame { + min-height: 24rem; + } + + .workspace-sidebar-shell { + height: calc(100vh - 0.75rem); + border-radius: 1.4rem; + } + + .hero-actions { + width: 100%; + gap: 0.7rem; + } + + .hero-actions .primary-button, + .hero-actions .secondary-button { + width: 100%; + } + + .landing-nav-button { + min-height: 2.7rem; + padding-inline: 1rem; + font-size: 0.94rem; + } + + .landing-hero-copy { + font-size: 1rem; + line-height: 1.75; + } + + .landing-footer-title { + font-size: 1.45rem; + } + + .landing-hero-copy, + .landing-footer-copy, + .landing-footer-credit, + .landing-footer-link { + line-height: 1.7; + } +} + +/* ===================================================================== + Landing redesign — "Workbench" narrative + Scoped under .l-shell. Reuses the shared :root tokens (--accent, + --text, --muted) at the top of this file, but adds local variables + for spacing + motion that only the landing needs. + + Sections: topbar · hero · workbench (sticky scroll) · bento · public + · final CTA · footer. + ===================================================================== */ + +.l-shell { + /* Local tokens layered on top of the global ones. Kept separate from + the workspace `.b-shell` block so the two surfaces can drift + visually without dragging each other along. + + Card colors went through a "washed blue" phase where they were + `rgba(10–14, 14–20, 22–32, …)` — bluish-grey on the dark page that + read as muddy. The current values are near-pure-black so the + cards feel like cut-outs against the page rather than slightly + lighter blue rectangles on top of it. The blue accent comes back + in via the per-tile inner radial gradient (set per-tile, not on + the token). */ + --l-radius: 14px; + --l-radius-lg: 20px; + --l-radius-sm: 10px; + --l-line: rgba(255, 255, 255, 0.06); + --l-line-strong: rgba(255, 255, 255, 0.10); + --l-card: #06080d; + --l-card-strong: #04060b; + --l-fg: #f5f8ff; + --l-fg-2: #c7cfdf; + --l-fg-3: #8a93a8; + --l-fg-4: #5e6677; + --l-ease: cubic-bezier(0.16, 1, 0.30, 1); + --l-duration: 320ms; + /* Accent companions for sections that need the full --accent-* set + (pricing card variants, glow halos). Base --accent / --accent-strong + / --accent-soft come from :root; these extend it so pricing-style + code can reference --accent-fg / --accent-tint / --accent-glow + without per-rule fallbacks. --bg-page is exposed as a token so + cards can punch a near-black "window cut into the page" element + (the Pro card's CTA + MOST POPULAR pill) using a token instead + of a magic hex. */ + --accent-fg: #ffffff; + --accent-tint: rgba(48, 100, 255, 0.14); + --accent-glow: rgba(48, 100, 255, 0.45); + --bg-page: #04070f; + + position: relative; + display: flex; + flex-direction: column; + min-height: 100vh; + color: var(--l-fg); + background: + radial-gradient(900px 480px at 50% -10%, rgba(48, 100, 255, 0.07), transparent 60%), + linear-gradient(180deg, #04070f 0%, #02040a 50%, #000 100%); + font-family: var(--font-dm-sans), "DM Sans", system-ui, sans-serif; + letter-spacing: -0.005em; + /* `overflow-x: clip` (not `hidden`) so the orbs don't trigger + horizontal scroll WITHOUT also breaking sticky positioning on + descendants — `hidden` creates a scroll container that disables + `position: sticky` for everything inside it. */ + overflow-x: clip; +} + +.l-shell h1, .l-shell h2, .l-shell h3 { + font-family: var(--font-space-grotesk), "Space Grotesk", "Inter", system-ui, sans-serif; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.05; + margin: 0; +} + +/* Soft accent orbs in the page background. Big + heavily blurred so + they read as ambience, not decoration. They're `position: fixed` + so they don't scroll-jitter. */ +.l-orb { + position: fixed; + border-radius: 999px; + filter: blur(110px); + pointer-events: none; + z-index: 0; + opacity: 0.55; +} +.l-orb-1 { + top: -20rem; + right: -10rem; + width: 36rem; + height: 36rem; + background: radial-gradient(circle, rgba(48, 100, 255, 0.30), transparent 70%); +} +.l-orb-2 { + bottom: 10rem; + left: -16rem; + width: 32rem; + height: 32rem; + background: radial-gradient(circle, rgba(70, 60, 200, 0.20), transparent 70%); + opacity: 0.45; +} +.l-grain { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.06; + background-image: radial-gradient(rgba(255, 255, 255, 0.7) 1px, transparent 1px); + background-size: 3px 3px; + mix-blend-mode: overlay; +} + +/* ── Topbar ───────────────────────────────────────────────────────── */ + +.l-topbar { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px) saturate(140%); + background: rgba(4, 7, 15, 0.65); + border-bottom: 1px solid var(--l-line); +} +.l-topbar-inner { + max-width: 1200px; + margin: 0 auto; + padding: 14px 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.l-brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--l-fg); +} +.l-brand-logo { + width: 32px; + height: 32px; +} +.l-brand-name { + font-family: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + font-weight: 600; + letter-spacing: -0.015em; + font-size: 15px; +} +.l-topbar-nav { + display: inline-flex; + align-items: center; + gap: 6px; +} +.l-topbar-link { + display: inline-flex; + align-items: center; + height: 34px; + padding: 0 14px; + font-size: 13.5px; + color: var(--l-fg-3); + border-radius: 8px; + transition: color 160ms var(--l-ease), background 160ms var(--l-ease); +} +.l-topbar-link:hover { + color: var(--l-fg); + background: rgba(255, 255, 255, 0.04); +} + +/* Visual-gap fix between the link cluster (Workflow / Features) and + the first button (Sign out, or Sign in with Google when logged + out). The nav uses `gap: 6px` and each link/button has 14 px + horizontal padding, which gives a 34 px text-to-text gap between + the two links — but the button's visible background starts + immediately at its 34 px box, so the EMPTY whitespace between + "Features" and the button's left edge is only 20 px (14 px link + pad + 6 px gap). Adding 14 px of left-margin to the first button + pushes its background-edge another 14 px right, so the empty + whitespace from "Features" matches the text-to-text gap before + it. Targets `button:first-of-type` so it works whether or not + "Sign out" is present (varies by auth state). */ +.l-topbar-nav > button:first-of-type { + margin-left: 14px; +} + +/* Mobile hamburger button + dropdown panel. Hidden on desktop via + `display: none` here; the mobile media query below flips the + visibility (hide `.l-topbar-nav`, show `.l-topbar-burger`). The + panel itself only renders when `menuOpen` is true in JSX. */ +.l-topbar-burger { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--l-line-strong); + border-radius: 8px; + color: var(--l-fg); + cursor: pointer; + transition: background 160ms var(--l-ease), border-color 160ms var(--l-ease), + transform 160ms var(--l-ease); +} +.l-topbar-burger:hover { background: rgba(255, 255, 255, 0.08); } +.l-topbar-burger:active { transform: scale(0.96); } +.l-topbar-burger:focus-visible { + outline: 2px solid rgba(48, 100, 255, 0.55); + outline-offset: 2px; +} + +/* Backdrop is a button so taps anywhere outside the panel close the + menu. Fixed full-screen, sits between page content and the panel. + Strong dim (~70 % black) so the page underneath reads as clearly + backgrounded — at lower opacities the hero title was visible + "behind" the menu and the layering felt unclear. */ +.l-topbar-menu-backdrop { + position: fixed; + inset: 0; + z-index: 19; + background: rgba(0, 0, 0, 0.70); + backdrop-filter: blur(4px); + border: 0; + padding: 0; + cursor: pointer; + /* Reset default button styling so it reads as an invisible overlay. */ + appearance: none; +} + +/* Floating panel anchored under the topbar via fixed positioning. + `position: fixed; top: 64px` keeps it directly below the sticky + header even after the page is scrolled — `position: absolute` would + anchor to the topbar's in-flow box (top of page), not its visible + position. Spans full viewport width with small inset (matches the + common mobile drawer / dropdown pattern). Background is the page's + own tone at high alpha (`rgba(2, 4, 9, 0.97)`), which gives the + same visual color as the bento tile (rgba(0,0,0,0.40) on top of + page bg #04070f) but is opaque enough that nothing behind the menu + bleeds through — at 40 % alpha the hero title was readable + underneath, breaking the "this is a layered surface" cue. */ +.l-topbar-menu { + position: fixed; + top: 64px; + left: 12px; + right: 12px; + z-index: 21; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + background: rgba(2, 4, 9, 0.97); + backdrop-filter: blur(18px) saturate(140%); + border: 1px solid var(--l-line-strong); + border-radius: 14px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 18px 48px rgba(0, 0, 0, 0.55); + animation: lFadeUp 200ms var(--l-ease); + animation-fill-mode: both; +} +.l-topbar-menu-link { + display: flex; + align-items: center; + /* Center the link text horizontally so the dropdown reads as a + stacked, centered menu (matches the centered Sign out + Enter + workspace buttons below the divider). */ + justify-content: center; + height: 40px; + padding: 0 12px; + border-radius: 8px; + font-size: 14px; + color: var(--l-fg-2); + transition: background 160ms var(--l-ease), color 160ms var(--l-ease); +} +.l-topbar-menu-link:hover { + color: var(--l-fg); + background: rgba(255, 255, 255, 0.04); +} +.l-topbar-menu-divider { + height: 1px; + background: var(--l-line); + margin: 4px 0; +} +.l-topbar-menu-action { + width: 100%; + justify-content: center; +} + +/* ── Eyebrow + buttons (used everywhere) ──────────────────────────── */ + +.l-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--font-geist-mono), "Geist Mono", ui-monospace, monospace; + font-size: 11px; + letter-spacing: 0.10em; + text-transform: uppercase; + color: var(--l-fg-3); +} +.l-eyebrow-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--accent-strong); + box-shadow: 0 0 12px rgba(48, 100, 255, 0.55); +} + +.l-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 42px; + padding: 0 18px; + border-radius: 10px; + border: 1px solid transparent; + font-family: inherit; + font-size: 14px; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--l-fg); + background: transparent; + cursor: pointer; + transition: background 160ms var(--l-ease), border-color 160ms var(--l-ease), + box-shadow 160ms var(--l-ease), transform 160ms var(--l-ease); + white-space: nowrap; +} +.l-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.l-btn-primary { + background: + linear-gradient(180deg, rgba(70, 110, 255, 0.95), rgba(38, 78, 220, 0.95)), + var(--accent); + border-color: rgba(170, 195, 255, 0.30); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + 0 8px 24px rgba(48, 100, 255, 0.30); + color: #ffffff; +} +.l-btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.22), + 0 12px 32px rgba(48, 100, 255, 0.45); +} +.l-btn-ghost { + background: rgba(255, 255, 255, 0.04); + border-color: var(--l-line-strong); +} +.l-btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(160, 178, 218, 0.30); +} +.l-btn-quiet { + background: transparent; + color: var(--l-fg-2); +} +.l-btn-quiet:hover { + color: var(--l-fg); + background: rgba(255, 255, 255, 0.04); +} +.l-btn-sm { + height: 34px; + padding: 0 14px; + font-size: 13px; + border-radius: 8px; +} +.l-btn-lg { + height: 52px; + padding: 0 26px; + font-size: 15.5px; + border-radius: 12px; +} +.l-glyph { + flex-shrink: 0; +} + +.l-notice { + border: 1px solid var(--l-line-strong); + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; + padding: 12px 14px; + font-size: 13.5px; + color: var(--l-fg-2); +} +.l-notice-warning { + border-color: rgba(255, 162, 100, 0.32); + color: #ffd6a8; + background: rgba(255, 162, 100, 0.08); +} + +/* ── Main + section heads ─────────────────────────────────────────── */ + +.l-main { + position: relative; + z-index: 1; + flex: 1; +} + +.l-section-head { + max-width: 760px; + margin: 0 auto; + text-align: center; + display: flex; + flex-direction: column; + gap: 18px; + /* 40px is the consistent gap between the section head and the + content that follows. Both the workbench and the bento use this + value so the spacing rhythm matches between the two sections. */ + padding-bottom: 40px; +} +.l-section-title { + font-size: clamp(28px, 4vw, 40px); + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.12; + color: var(--l-fg); +} + +/* ── Hero ─────────────────────────────────────────────────────────── */ + +.l-hero { + padding: 80px 32px 90px; + max-width: 1200px; + margin: 0 auto; +} +/* Single-column stacked layout: copy on top, artifact mock below at + full width. Replaces the earlier 2-col grid so the title gets the + centered stage and the screenshot reads bigger underneath. */ +.l-hero-stack { + display: flex; + flex-direction: column; + align-items: center; + gap: 56px; +} +.l-hero-copy { + display: flex; + flex-direction: column; + gap: 24px; + align-items: center; + text-align: center; + /* Wide enough to let the longest title line ("Tailor every job + application") fit on one row at the desktop font-size. */ + max-width: 1080px; +} +.l-hero-eyebrow { + padding: 6px 12px 6px 10px; + border: 1px solid var(--l-line-strong); + border-radius: 999px; + background: rgba(48, 100, 255, 0.06); +} +.l-hero-title { + display: flex; + flex-direction: column; + gap: 4px; + font-size: clamp(40px, 5.6vw, 68px); + font-weight: 600; + letter-spacing: -0.030em; + line-height: 1.06; +} +.l-hero-title span { + display: inline-block; + /* Keep each of the three title lines on a single physical row. + We're explicitly choosing the breaks in JSX, not letting the + browser wrap the long line into a pyramid. */ + white-space: nowrap; +} +.l-hero-title-accent { + background: linear-gradient(180deg, #ffffff 0%, #8db4ff 90%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.l-hero-sub { + font-size: 18px; + line-height: 1.55; + color: var(--l-fg-2); + max-width: 640px; + margin: 0; +} +.l-hero-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: center; + margin-top: 4px; +} +.l-hero-pills { + list-style: none; + margin: 0; + padding: 0; + display: inline-flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + justify-content: center; + margin-top: 8px; + font-size: 12.5px; + color: var(--l-fg-3); +} +.l-hero-pills li { + display: inline-flex; + align-items: center; + gap: 8px; +} +.l-hero-pills li::before { + content: ""; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--l-fg-4); +} + +/* Stagger fade-up — used by hero text + everywhere else with the + .l-fade-up class. The `animation-fill-mode: backwards` keeps the + element invisible until its delay starts so the page doesn't flash + the un-animated state. */ +.l-fade-up { + opacity: 0; + transform: translateY(14px); + animation: lFadeUp 700ms var(--l-ease) forwards; + animation-fill-mode: both; +} +@keyframes lFadeUp { + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Hero artifact preview — the "live" mock of the workspace's artifact + viewer. Pure HTML/CSS so it scales pixel-perfectly. The streaming + caret blinks via @keyframes lBlink. */ +.l-hero-visual { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} +.l-artifact { + position: relative; + width: 100%; + /* Wider than the title block above (1080px) so the artifact reads + as the showcase. ~20% wider than the previous 920px to flatten + the aspect ratio — user said it "felt too tall" after the + experience section was added. */ + max-width: 1120px; + animation: lFadeUp 800ms var(--l-ease) 700ms; + animation-fill-mode: both; +} +.l-artifact-glow { + position: absolute; + inset: -40px; + border-radius: 32px; + /* Was 0.25 — produced a bright blue halo extending past the image + edges that, layered on top of the workspace's own `b-shell` + ambient orb baked into the screenshot PNG, read as a "whitish + hot spot" with serrated banding from the underlying noise + texture. Dropped to 0.10 so the glow only acts as a soft + framing edge rather than a competing light source. */ + background: radial-gradient(60% 60% at 60% 40%, rgba(48, 100, 255, 0.10), transparent 70%); + filter: blur(40px); + z-index: 0; +} +/* Real workspace screenshot rendered inside the artifact wrapper. + Drop shadow + subtle border + rounded corners give it the same + "centerpiece card" feel the hand-built mock had, just sourced from + an actual product capture instead of HTML/CSS. */ +.l-artifact-image { + position: relative; + z-index: 1; + display: block; + width: 100%; + height: auto; + border-radius: var(--l-radius-lg); + border: 1px solid var(--l-line-strong); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 28px 72px rgba(0, 0, 0, 0.55); + /* Keep the image inside the rounded corners. */ + overflow: hidden; +} + +/* Streaming caret — used in the hero artifact + a few other places. */ +.l-artifact-caret { + display: inline-block; + width: 2px; + height: 1.05em; + background: var(--accent-strong); + margin-left: 3px; + vertical-align: -2px; + animation: lBlink 1s step-end infinite; +} +@keyframes lBlink { + 50% { opacity: 0; } +} + +/* ── Workbench (sticky scroll narrative) ─────────────────────────── */ + +.l-workbench { + padding: 90px 32px 60px; + max-width: 1200px; + margin: 0 auto; +} +.l-workbench-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 80px; + align-items: start; +} +.l-workbench-visual { + position: sticky; + top: 92px; + /* Sticky container height is shorter than viewport (was + `100vh - 92px`). With `justify-content: center` below, the + stage's vertical center sits ~80px higher than viewport center, + which lines it up with where step 01's centered text appears on + first scroll into the section. The 200px subtrahend accounts for + the 92px sticky top + a 108px lift that puts the stage's center + close to step text center for both first-view and active state. */ + height: calc(100vh - 200px); + display: flex; + flex-direction: column; + align-items: center; + /* Center-pin: the [stage + rail] group sits in the vertical middle + of the sticky container, which is one viewport tall. As the steps + scroll past on the right, the visual reads as floating at viewport + center the entire way down. Step start/end alignment with the + stage edges no longer matters because the stage is always + centered — earlier the stage was stretched (`flex: 1 1 auto`) to + match step 04's bottom, which left ~400px of dead dark space + inside the card since the mock content is fixed-size. */ + justify-content: center; + gap: 24px; +} +.l-workbench-visual-stage { + position: relative; + width: 100%; + max-width: 480px; + /* Fixed-aspect stage instead of flex-stretching to fill the sticky + container. 1/1 gives 480 × 480 on desktop — close to the natural + height of the mocks (~280–360px each) once the inner result/doc + containers stop flex-stretching, so empty space is minimal once + the mock is centered. The max-height cap keeps it from + overflowing on short viewports (e.g. laptop with bookmarks bar). */ + flex: 0 0 auto; + aspect-ratio: 1 / 1; + max-height: calc(100vh - 200px); + border-radius: var(--l-radius-lg); + /* Same treatment as the workspace's JD review blocks + (`.b-jd-block`): a solid 40% black overlay over the page, with a + subtle border. The earlier blue corner-glow gradient was making + the stage feel "lit up" compared to the much darker, cleaner + cards on the live JD page. */ + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line-strong); + overflow: hidden; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 24px 60px rgba(0, 0, 0, 0.55); +} +.l-workbench-rail { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--l-line); + border-radius: 999px; +} +.l-workbench-rail-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 28px; + background: transparent; + border: none; + border-radius: 999px; + color: var(--l-fg-3); + font-family: var(--font-geist-mono), monospace; + font-size: 11px; + cursor: pointer; + transition: all 160ms var(--l-ease); +} +.l-workbench-rail-step:hover { + color: var(--l-fg); + background: rgba(255, 255, 255, 0.04); +} +.l-workbench-rail-step.is-active { + background: rgba(48, 100, 255, 0.20); + color: var(--l-fg); +} + +/* The four step mocks live stacked inside .l-workbench-visual-stage — + only the active one has opacity 1, the others fade out. The crossfade + feels like the workspace itself walking through the steps. */ +.l-workbench-mock { + position: absolute; + inset: 0; + padding: 26px; + display: flex; + flex-direction: column; + /* Center content vertically so the four mocks read at the same + visual density even though their child counts vary (resume has 5 + elements, search/analysis have ~7 each, JD has 4). Without this + the short mocks pile up at the top and leave a big dark gap at + the bottom of the 480 × 600 stage. */ + justify-content: center; + gap: 14px; + opacity: 0; + transform: translateY(8px); + transition: opacity 360ms var(--l-ease), transform 360ms var(--l-ease); + pointer-events: none; +} +.l-workbench-mock.is-active { + opacity: 1; + transform: translateY(0); +} + +/* Per-step inline visual wrapper. On desktop the shared sticky visual + (`.l-workbench-visual`) is the source of truth, so this is hidden. + The mobile media query below flips: shared visual hidden, this one + shown — it provides each step's mock inline above its text. */ +.l-workbench-step-visual { + display: none; +} + +.l-mock-eyebrow { + font-family: var(--font-geist-mono), monospace; + font-size: 10.5px; + letter-spacing: 0.12em; + color: var(--l-fg-3); + text-transform: uppercase; +} + +/* Step 01 — Resume mock + Models the workspace's parsed-profile hero: filename pill at the + top, name + role + location, then a stats row and a skills chip + cluster. Replaces the old "Name / Role / Skills" three-row form + layout, which read as a generic data table rather than the actual + ResumeIntake page. */ +.l-mock-file-pill { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 10px; + padding: 6px 10px 6px 12px; + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line-strong); + border-radius: 999px; +} +.l-mock-file-name { + font-family: var(--font-geist-mono), monospace; + font-size: 12px; + color: var(--l-fg); +} +.l-mock-file-tag { + font-family: var(--font-geist-mono), monospace; + font-size: 9.5px; + letter-spacing: 0.10em; + padding: 2px 7px; + background: rgba(127, 224, 176, 0.16); + color: #9be8c0; + border-radius: 999px; +} +.l-mock-hero { + display: flex; + flex-direction: column; + gap: 4px; +} +.l-mock-hero-name { + font-family: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--l-fg); +} +.l-mock-hero-meta { + font-size: 12.5px; + color: var(--l-fg-3); +} +.l-mock-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.l-mock-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line); + border-radius: 8px; +} +.l-mock-stat-num { + font-family: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + font-size: 20px; + font-weight: 600; + color: var(--l-fg); + line-height: 1; +} +.l-mock-stat-label { + font-family: var(--font-geist-mono), monospace; + font-size: 10px; + letter-spacing: 0.10em; + text-transform: uppercase; + color: var(--l-fg-3); +} + +/* Step 02 — Search mock */ +.l-mock-search-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 14px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--l-line-strong); + border-radius: 10px; + font-size: 13px; +} +.l-mock-search-icon { color: var(--l-fg-3); } +.l-mock-search-text { color: var(--l-fg-2); flex: 1 1 auto; } +.l-mock-search-divider { + width: 1px; + align-self: stretch; + background: var(--l-line-strong); +} +.l-mock-search-loc { + font-size: 12px; + color: var(--l-fg-3); + white-space: nowrap; +} +.l-mock-matches-head { + font-family: var(--font-geist-mono), monospace; + font-size: 10px; + letter-spacing: 0.10em; + color: var(--l-fg-3); + margin-top: 2px; +} +.l-mock-filters { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.l-mock-filter { + padding: 4px 10px; + border: 1px solid var(--l-line-strong); + border-radius: 8px; + font-size: 11.5px; + color: var(--l-fg-2); +} +.l-mock-results { + display: flex; + flex-direction: column; + gap: 8px; + /* Was `flex: 1` so the results list stretched to fill a 600+ stage. + With the smaller, naturally-sized stage we let it size to its + three result cards. Empty stretch made the bottom of the mock + read as dead dark space. */ + flex: 0 1 auto; +} +.l-mock-result { + padding: 10px 12px; + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} +.l-mock-result-top { + border-color: rgba(48, 100, 255, 0.45); + box-shadow: 0 0 0 1px rgba(48, 100, 255, 0.25); +} +.l-mock-result-badge { + display: inline-flex; + align-self: flex-start; + padding: 2px 8px; + background: rgba(48, 100, 255, 0.18); + color: #c5d8ff; + border-radius: 6px; + font-family: var(--font-geist-mono), monospace; + font-size: 9.5px; + letter-spacing: 0.10em; + margin-bottom: 4px; +} +.l-mock-result-title { + font-size: 13px; + color: var(--l-fg); + font-weight: 500; +} +.l-mock-result-meta { + font-size: 11.5px; + color: var(--l-fg-3); +} + +/* Step 03 — JD mock */ +.l-mock-jd-title { + font-family: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--l-fg); +} +.l-mock-jd-sub { + font-size: 12.5px; + color: var(--l-fg-3); + margin-top: -6px; +} +/* Three big-number metric tiles — match the JDReview page's + "Match score 87% · Hard skills 12 · Years 5+" hero strip. The + leftmost tile (`-accent`) is the eye-catcher, tinted blue like the + workspace's match-score chip. */ +.l-mock-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 4px; +} +.l-mock-metric { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line); + border-radius: 10px; +} +.l-mock-metric-accent { + background: rgba(48, 100, 255, 0.12); + border-color: rgba(48, 100, 255, 0.32); +} +.l-mock-metric-num { + font-family: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--l-fg); + line-height: 1; +} +.l-mock-metric-accent .l-mock-metric-num { color: #cfddff; } +.l-mock-metric-unit { + font-size: 14px; + font-weight: 500; + color: var(--l-fg-2); + margin-left: 2px; +} +.l-mock-metric-label { + font-family: var(--font-geist-mono), monospace; + font-size: 9.5px; + letter-spacing: 0.10em; + text-transform: uppercase; + color: var(--l-fg-3); +} +.l-mock-skills-block { + display: flex; + flex-direction: column; + gap: 6px; +} +.l-mock-skills-head { + font-family: var(--font-geist-mono), monospace; + font-size: 10px; + letter-spacing: 0.10em; + color: var(--l-fg-3); +} +.l-mock-skills { + display: flex; + gap: 5px; + flex-wrap: wrap; +} +.l-mock-chip { + padding: 3px 9px; + border-radius: 6px; + font-size: 11.5px; + border: 1px solid var(--l-line); +} +.l-mock-chip-hard { + background: rgba(255, 255, 255, 0.04); + color: var(--l-fg); +} +.l-mock-chip-soft { + background: rgba(48, 100, 255, 0.14); + border-color: rgba(48, 100, 255, 0.32); + color: #cfddff; +} + +/* Step 04 — Analysis mock + Models the AnalysisRunner page's pipeline of agent cards. Each row + is a stage card with a status dot, agent title, percent on the + right, and a one-line detail. The active (running) stage gets a + tinted background, a pulsing dot, and a progress bar. Replaces the + old "Tailoring / Review / Resume gen" timeline + faux doc preview, + which was generic and didn't match what the user actually sees. */ +.l-mock-pipeline { + display: flex; + flex-direction: column; + gap: 8px; +} +.l-mock-stage { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line); + border-radius: 8px; +} +.l-mock-stage-running { + background: rgba(48, 100, 255, 0.10); + border-color: rgba(48, 100, 255, 0.32); +} +.l-mock-stage-pending { + opacity: 0.55; +} +.l-mock-stage-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.10); + border: 1px solid var(--l-line-strong); + font-size: 9px; + color: transparent; + flex-shrink: 0; + margin-top: 1px; +} +.l-mock-stage-dot-done { + background: rgba(127, 224, 176, 0.18); + border-color: rgba(127, 224, 176, 0.45); + color: #9be8c0; + font-weight: 700; +} +.l-mock-stage-dot-running { + background: var(--accent-strong); + border-color: var(--accent-strong); + box-shadow: 0 0 0 4px rgba(48, 100, 255, 0.20); + animation: lPulse 1400ms var(--l-ease) infinite; +} +.l-mock-stage-body { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1 1 auto; + min-width: 0; +} +.l-mock-stage-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.l-mock-stage-title { + font-size: 13px; + font-weight: 500; + color: var(--l-fg); +} +.l-mock-stage-pct { + font-family: var(--font-geist-mono), monospace; + font-size: 10.5px; + color: var(--l-fg-3); + letter-spacing: 0.04em; +} +.l-mock-stage-running .l-mock-stage-pct { color: #cfddff; } +.l-mock-stage-detail { + font-size: 11.5px; + color: var(--l-fg-3); +} +.l-mock-stage-bar { + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; + margin-top: 4px; +} +.l-mock-stage-fill { + width: 62%; + height: 100%; + background: var(--accent-strong); + border-radius: 999px; +} + +/* The right-column scroll narrative — four blocks each ~viewport-tall. */ +.l-workbench-steps { + display: flex; + flex-direction: column; + gap: 28px; +} +.l-workbench-step { + /* 48vh per step — enough scroll distance for the IntersectionObserver + to fire one step at a time without stacking. */ + min-height: 48vh; + display: flex; + flex-direction: column; + /* Vertically center text inside each block so the step copy aligns + with the center-pinned visual on the left. With `flex-start`, + step 01's text sat at the very top of the column on first scroll + into the section, ~190px above the centered visual stage — they + read as off-axis. Centering pushes step 01 down to roughly the + middle of its block, much closer to the visual's vertical center. + Steps 02–04 are unchanged in feel because the IntersectionObserver + "active" state already triggers when each block crosses the + viewport's middle band, so centered text lines up with the + centered visual when active. */ + justify-content: center; + gap: 18px; + padding: 8px 0 32px; + opacity: 0.45; + transition: opacity 360ms var(--l-ease); +} +.l-workbench-step.is-active { + opacity: 1; +} +.l-workbench-eyebrow { + display: inline-block; +} +.l-workbench-title { + font-size: clamp(24px, 3vw, 32px); + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.15; + color: var(--l-fg); +} +.l-workbench-body { + font-size: 16px; + line-height: 1.65; + color: var(--l-fg-2); + max-width: 480px; + margin: 0; +} +.l-workbench-aside { + font-size: 14px; + line-height: 1.6; + color: var(--l-fg-3); + max-width: 480px; + padding-left: 14px; + border-left: 2px solid rgba(48, 100, 255, 0.32); + margin: 0; +} + +/* ── Bento ────────────────────────────────────────────────────────── */ + +.l-bento { + padding: 90px 32px; + max-width: 1100px; + margin: 0 auto; +} +/* Bento section head matches the workbench: eyebrow centered above + title, title centered below. Arrows live beside the dots row, not + in the head. The padding-bottom matches the workbench's exactly so + the section-head → content gap is identical between both sections. */ +.l-bento-nav-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--l-line-strong); + color: var(--l-fg-2); + cursor: pointer; + transition: background 160ms var(--l-ease), border-color 160ms var(--l-ease), + color 160ms var(--l-ease), transform 160ms var(--l-ease), + opacity 160ms var(--l-ease); +} +.l-bento-nav-btn:hover:not(:disabled) { + background: rgba(48, 100, 255, 0.12); + border-color: rgba(48, 100, 255, 0.45); + color: var(--l-fg); +} +.l-bento-nav-btn:active:not(:disabled) { transform: scale(0.96); } +.l-bento-nav-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Strip wrapper holds the scrolling strip + the dot indicators. The + strip itself is overflow:auto with snap, so only the active tile is + visible; arrows + dots drive scroll position. */ +.l-bento-strip-wrap { + position: relative; +} +.l-bento-strip { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-width: none; /* Firefox */ + /* No internal padding — each tile is exactly 100% of the strip's + visible width, so the snap points line up with tile boundaries. */ +} +.l-bento-strip::-webkit-scrollbar { display: none; } + +.l-bento-tile { + position: relative; + /* One tile == one full strip width. Setting `min-width: 0` would + usually allow flex shrink, but with overflow-x scroll we want + each tile to keep its 100% width and scroll out. */ + flex: 0 0 100%; + scroll-snap-align: start; + scroll-snap-stop: always; + /* Match the workspace's .b-jd-block treatment — flat + rgba(0, 0, 0, 0.40) overlay with a subtle border. Drops the blue + corner glow so the carousel reads as the same surface family as + the workbench mock above. */ + background: rgba(0, 0, 0, 0.40); + border: 1px solid var(--l-line-strong); + border-radius: var(--l-radius-lg); + padding: 36px 40px; + display: flex; + flex-direction: column; + gap: 14px; + overflow: hidden; + /* Tile is a "stage" so it gets some vertical breathing room; min + keeps the carousel a consistent height as the user clicks + through varying-content tiles. */ + min-height: 360px; +} + +/* Carousel controls row — prev arrow, dots, next arrow. Centered + below the strip. */ +.l-bento-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 28px; +} +.l-bento-dots { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.l-bento-dot { + width: 8px; + height: 8px; + border-radius: 999px; + border: none; + background: var(--l-line-strong); + cursor: pointer; + padding: 0; + transition: width 200ms var(--l-ease), background 200ms var(--l-ease); +} +.l-bento-dot:hover { + background: rgba(160, 178, 218, 0.32); +} +.l-bento-dot.is-active { + width: 24px; + background: var(--accent-strong); + box-shadow: 0 0 12px rgba(48, 100, 255, 0.5); +} +.l-bento-eyebrow { + font-family: var(--font-geist-mono), monospace; + font-size: 10.5px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--l-fg-3); +} +.l-bento-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--l-fg); +} +.l-bento-body { + font-size: 14px; + line-height: 1.6; + color: var(--l-fg-2); + margin: 0; + flex: 1; +} +.l-bento-providers { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 6px; +} +.l-bento-provider { + font-family: var(--font-geist-mono), monospace; + font-size: 11px; + padding: 4px 10px; + border: 1px solid var(--l-line); + border-radius: 6px; + color: var(--l-fg-2); +} +.l-bento-format-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} +.l-bento-format { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + background: rgba(48, 100, 255, 0.15); + border: 1px solid rgba(48, 100, 255, 0.35); + border-radius: 8px; + font-family: var(--font-geist-mono), monospace; + font-size: 11.5px; + color: #cfddff; +} +.l-bento-format-divider { + width: 1px; + height: 16px; + background: var(--l-line-strong); +} +.l-bento-format-tag { + font-family: var(--font-geist-mono), monospace; + font-size: 11px; + color: var(--l-fg-3); + padding: 4px 10px; + border: 1px solid var(--l-line); + border-radius: 6px; +} +.l-bento-chat { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; +} +.l-bento-chat-turn { + font-size: 12.5px; + padding: 8px 12px; + border-radius: 10px; + max-width: 90%; +} +.l-bento-chat-turn-bot { + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--l-line); + color: var(--l-fg-2); + align-self: flex-start; +} +.l-bento-chat-turn-user { + background: rgba(48, 100, 255, 0.18); + border: 1px solid rgba(48, 100, 255, 0.32); + color: var(--l-fg); + align-self: flex-end; +} + +/* ── Pricing ─────────────────────────────────────────────────────── */ + +/* Three-tier pricing card. Middle "Pro" tier is the focal anchor — + solid accent (blue) gradient fill with white text and dark MOST + POPULAR pill + Get Pro button (both using --bg-page so they read as + windows cut into the filled card exposing the page color behind). + Outer Free / Business cards share an accent border + soft outer + glow but stay dark inside — no interior tint to compete with the + filled Pro card. CTAs alternate contrast across the row: accent on + dark, dark on accent, accent on dark. + + This is intentionally the same visual structure as HelpmateAI's + pricing.tsx (mint accent there). One vocabulary across both + products keeps the design system coherent. */ + +.l-pricing { + padding: 90px 32px 70px; + max-width: 1200px; + margin: 0 auto; +} + +.l-pricing-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + margin-top: 8px; + align-items: stretch; +} + +.l-pricing-card { + position: relative; + display: flex; + flex-direction: column; + gap: 18px; + padding: 28px; + border-radius: var(--l-radius-lg); + background: var(--l-card); + border: 1px solid var(--l-line); + transition: + background var(--l-duration) var(--l-ease), + border-color var(--l-duration) var(--l-ease), + box-shadow var(--l-duration) var(--l-ease), + transform var(--l-duration) var(--l-ease); +} + +/* Free + Business cards: accent border + outer glow, interior stays + neutral dark. The Pro card sits between them solid-filled, so the + accent rim on the outers ties the row into one coherent group + without diluting the focal anchor. + Tight glow: 18px blur at ~28% alpha keeps the halo inside the + gap between cards. The earlier 32px / 45% halo bled across card + boundaries and made adjacent cards look like they were sharing + one wash of blue. */ +.l-pricing-card:not(.is-featured) { + border-color: var(--accent); + box-shadow: 0 0 18px rgba(48, 100, 255, 0.28); +} + +.l-pricing-card-head { + display: flex; + flex-direction: column; + gap: 6px; +} + +.l-pricing-name { + margin: 0; + font-family: var(--font-space-grotesk), "Space Grotesk", "Inter", system-ui, sans-serif; + font-size: 18px; + font-weight: 600; + color: var(--l-fg); + letter-spacing: -0.005em; +} + +.l-pricing-blurb { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: var(--l-fg-3); +} + +.l-pricing-price { + display: flex; + align-items: baseline; + gap: 6px; + margin: 0; +} +.l-pricing-price .num { + font-family: var(--font-space-grotesk), "Space Grotesk", "Inter", system-ui, sans-serif; + font-size: 44px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.025em; + color: var(--l-fg); +} +.l-pricing-price .per { + font-size: 13px; + color: var(--l-fg-3); +} + +.l-pricing-cta { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 11px 14px; + border-radius: var(--l-radius); + background: var(--accent); + color: var(--accent-fg); + border: 1px solid transparent; + font-size: 14px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + /* Chrome's modern + ) : null} + + + {/* Mobile hamburger button — hidden on desktop via CSS. The + dropdown panel below renders only when open. */} + + + {menuOpen ? ( + <> + {/* Backdrop covers the rest of the page so a tap outside + the menu closes it. The panel itself stops propagation + so taps inside don't bubble to the backdrop. */} + + ) : null} + + + + ) : null} + + +
+ void onPrimaryCta()} + anyActionPending={anyActionPending} + /> + + + + + + void onPrimaryCta()} + anyActionPending={anyActionPending} + authStatus={authStatus} + userId={authSession?.app_user?.id ?? ""} + /> + + void onPrimaryCta()} + anyActionPending={anyActionPending} + /> +
+ + + + ); +} + +// ─── Hero ───────────────────────────────────────────────────────────── + +type HeroProps = { + authError: string | null; + authStatus: LandingAuthStatus; + isSignedIn: boolean; + pendingAction: LandingPendingAction; + onPrimaryCta: () => void; + anyActionPending: boolean; +}; + +function LandingHero({ + authError, + authStatus, + isSignedIn, + pendingAction, + onPrimaryCta, + anyActionPending, +}: HeroProps) { + const ctaLabel = isSignedIn + ? pendingAction === "handoff" + ? "Opening workspace…" + : "Enter workspace" + : authStatus === "restoring" + ? "Restoring session…" + : pendingAction === "signin" + ? "Redirecting…" + : "Sign in with Google"; + + return ( +
+
+
+ + AI-powered application workbench + + + {/* Three deliberate lines so the title reads as a stack + rather than a balanced wrap-pyramid: + Tailor every job application + with an + AI workbench. + Each span stagger-fades on its own delay (.l-fade-up). */} +

+ + Tailor every job application + + + with an + + + AI workbench + +

+ +

+ Upload your resume, find a role you actually want, review the + job description, and walk away with a tailored resume and + cover letter. +

+ + {authError ? ( +
{authError}
+ ) : null} + +
+ + + + View on GitHub + +
+ +
    +
  • Smart resume reader
  • +
  • 12k+ live jobs
  • +
  • Tailored Word + PDF
  • +
  • Built-in AI assistant
  • +
+
+ + {/* Real workspace screenshot below the title at full width. + The wrapper handles the glow + drop shadow + accent corner + so the image feels like the centerpiece of the hero. */} +
+
+
+ Job Application Copilot workspace — Job Search with results and saved jobs +
+
+
+
+ ); +} + +// ─── Workbench (sticky scroll narrative) ────────────────────────────── +// +// Left column is `position: sticky` and stays in view while the right +// column scrolls through four step blocks. An IntersectionObserver +// watches each step block and updates `currentStep`; the left column +// has all four visuals stacked, only the active one has opacity 1. +// +// This is the centerpiece of the page — the workflow narrative. + +function WorkbenchSection() { + const [currentStep, setCurrentStep] = useState(0); + const stepRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + // Use a single "horizontal line" observer in the middle of the + // viewport. With rootMargin -50% top / -50% bottom, the + // observer's effective viewport collapses to a 1px line at the + // viewport's vertical center — the step whose block crosses that + // line is the active one. This avoids the failure mode where a + // step block that's taller than the narrowed band can never reach + // the threshold ratio. + const observers: IntersectionObserver[] = []; + stepRefs.current.forEach((node, index) => { + if (!node) return; + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setCurrentStep(index); + } + }); + }, + { + rootMargin: "-50% 0px -50% 0px", + threshold: 0, + }, + ); + observer.observe(node); + observers.push(observer); + }); + return () => { + observers.forEach((o) => o.disconnect()); + }; + }, []); + + return ( +
+
+ Four steps · One flow +

+ From a fresh resume to job ready application +

+
+ +
+
+
+ + + + +
+ {/* Mini step rail so the user sees they're in a 4-step + narrative even on smaller screens where the sticky + breaks down. */} +
+ {WORKBENCH_STEPS.map((step, idx) => ( + + ))} +
+
+ +
+ {WORKBENCH_STEPS.map((step, idx) => ( +
{ + stepRefs.current[idx] = node; + }} + className={`l-workbench-step ${ + currentStep === idx ? "is-active" : "" + }`} + > + {/* Per-step inline visual — only shown on mobile via CSS + (.l-workbench-step-visual { display: none } by default, + flipped to block at the workbench's mobile breakpoint). + The desktop sticky visual is hidden in that same media + query, so each layout has exactly one visual surface. */} +
+ {idx === 0 ? : null} + {idx === 1 ? : null} + {idx === 2 ? : null} + {idx === 3 ? : null} +
+ + {step.eyebrow} + +

{step.title}

+

{step.body}

+

{step.aside}

+
+ ))} +
+
+
+ ); +} + +// Four small "visual mocks" — pure HTML/CSS, each represents the active +// state of one workspace step. Stacked behind each other; only the +// currently-active one has opacity 1. These are intentionally minimal — +// they communicate the *shape* of each step, not pixel-perfect product +// renderings. + +function WorkbenchVisual0({ active }: { active: boolean }) { + return ( +
+
STEP 01 · RESUME
+
+ resume_v3.pdf + PARSED +
+
+
Aria Patel
+
+ Staff ML Engineer · San Francisco +
+
+
+
+ 12 + roles +
+
+ 27 + skills +
+
+ 9 + years +
+
+
+
SKILLS DETECTED
+
+ Python + PyTorch + CUDA + Triton + +12 more +
+
+
+ ); +} + +function WorkbenchVisual1({ active }: { active: boolean }) { + return ( +
+
STEP 02 · JOB SEARCH
+
+ + machine learning engineer + + Remote +
+
+ Source · 2 + Mode · Remote + Posted · 7d + Sort · Best match +
+
47 MATCHES · BY RELEVANCE
+
+
+ ★ TOP MATCH +
Senior ML Engineer
+
Stripe · greenhouse · Remote
+
+
+
ML Engineer, Inference
+
Pinterest · greenhouse
+
+
+
Founding ML Engineer
+
Notion · ashby
+
+
+
+ ); +} + +function WorkbenchVisual2({ active }: { active: boolean }) { + return ( +
+
STEP 03 · JOB DETAIL
+
Senior ML Engineer, Inference
+
Anthropic · San Francisco · Hybrid
+
+
+
+ 87% +
+
Match score
+
+
+
12
+
Hard skills
+
+
+
5+
+
Years req
+
+
+
+
HARD SKILLS · 5 OF 12
+
+ Python + CUDA + Triton + Distributed + Postgres +
+
+
+
SOFT SKILLS
+
+ Mentorship + Cross-functional + Pragmatic +
+
+
+ ); +} + +function WorkbenchVisual3({ active }: { active: boolean }) { + return ( +
+
STEP 04 · ANALYSIS
+
+
+ +
+
+ Matchmaker + 100% +
+
Scored role fit
+
+
+
+ +
+
+ Forge agent + 100% +
+
Drafted tailored resume
+
+
+
+ +
+
+ Gatekeeper + 62% +
+
Reviewing outputs…
+
+
+
+
+
+
+ +
+
+ Cover letter agent + standby +
+
+
+
+
+ ); +} + +// ─── Bento (single-tile carousel) ───────────────────────────────────── +// +// Each tile is a full-width "stage" — only ONE is visible at a time and +// the user navigates with the arrow buttons or trackpad swipe. This +// replaces the side-by-side strip where every tile was visible at once, +// which read as a wall-of-tiles instead of a focused "now look at this +// one feature" carousel. +// +// Implementation: +// - Strip is overflow-x: auto + scroll-snap-type: x mandatory. +// - Each tile is `flex: 0 0 100%` so only one fills the visible +// strip at a time. +// - Arrow buttons + dot indicators both drive `scrollToIndex()`. +// - Scroll listener derives the active index from scrollLeft so +// swipe / drag interactions sync the dots without React state +// fighting the user. + +const BENTO_TILES_COUNT = 4; + +function BentoSection() { + const stripRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + function scrollToIndex(index: number) { + const strip = stripRef.current; + if (!strip) return; + const clamped = Math.max(0, Math.min(BENTO_TILES_COUNT - 1, index)); + // Width per tile = strip's clientWidth (since each tile is 100% + // wide). scrollTo by `clamped * width` lands on the tile's left. + strip.scrollTo({ + left: clamped * strip.clientWidth, + behavior: "smooth", + }); + } + + // Keep `activeIndex` in sync with the user's scroll/swipe position. + // Debounce-ish via rAF to avoid spamming setState on every scroll + // tick. + useEffect(() => { + const strip = stripRef.current; + if (!strip) return; + let rafId = 0; + function onScroll() { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + const width = strip!.clientWidth; + if (!width) return; + const idx = Math.round(strip!.scrollLeft / width); + setActiveIndex(idx); + }); + } + strip.addEventListener("scroll", onScroll, { passive: true }); + return () => { + strip.removeEventListener("scroll", onScroll); + cancelAnimationFrame(rafId); + }; + }, []); + + return ( +
+ {/* Section head matches the workbench pattern: eyebrow centered + above title, title centered below. Arrows live below the + carousel beside the dots — keeps the head clean and matches + the standard carousel pattern. */} +
+ Built into the workbench +

+ Everything else worth knowing about +

+
+ +
+
+
+ 12,000+ open jobs +

+ Greenhouse · Lever · Ashby · Workday +

+

+ Live listings from 130+ companies including Stripe, Pinterest, + Anthropic, Notion, Walmart, and Disney. Refreshed several + times a day so you're always seeing what's actually open. +

+
+ greenhouse + lever + ashby + workday +
+
+ +
+ Polished exports +

Two themes, two formats

+

+ Pick a clean ATS-safe layout or a more polished neutral look. + Download as Word or PDF — both are identical, so use whichever + your application portal asks for. +

+
+ PDF + DOCX + + classic_ats + professional_neutral +
+
+ +
+ No resume? No problem. +

Chat one into existence

+

+ Don't have a resume yet? Chat with our AI — answer + questions naturally, change your mind whenever, and we'll + polish everything into a clean resume at the end. Your draft + saves for 7 days. +

+
+
+ What's your latest role? +
+
+ Senior ML Engineer at Stripe +
+
+ +
+
+
+ +
+ Built-in AI assistant +

+ Get instant answers about your application +

+

+ Ask anything — which skills to highlight, how to phrase your + summary, what gaps to address. The AI reads your resume and + the job description so the advice is actually relevant to + you. +

+
+
+ What gaps should I address for this role? +
+
+ Looking at this role, you'd benefit from emphasizing + your distributed-systems work + +
+
+
+
+ + {/* Carousel nav row — prev arrow, dots, next arrow. Centered + below the strip. Active dot widens into a pill; arrows + disable at the ends. */} +
+ +
+ {Array.from({ length: BENTO_TILES_COUNT }).map((_, i) => ( +
+ +
+
+
+ ); +} + +function ArrowGlyph({ direction }: { direction: "left" | "right" }) { + return ( + + + + + ); +} + +// ─── Pricing ────────────────────────────────────────────────────────── +// +// Three-tier card with the middle "Pro" tier filled-and-floated as the +// focal anchor. Same visual structure as HelpmateAI's landing-pricing +// (mint accent over there, electric blue here). Free + Pro CTAs route +// through the existing onPrimaryCta auth handoff so a click here is +// identical to clicking "Sign in with Google" in the hero. Business +// is a mailto until there's a real sales flow. +// +// Tier caps are aspirational — there's no backend enforcement of these +// values yet; that's a follow-up. The numbers below are stylistically +// matched to HelpmateAI's pricing matrix (Free / Pro $9 / Business +// $39 per seat) so the two products price coherently as siblings. + +// PricingSection takes everything HeroProps does (auth state + the +// primary CTA handler) plus the Supabase user_id used to bind the +// LS hosted checkout URL to the right account. userId is "" until the +// user is signed in; the per-tier handler routes through the auth +// flow first in that case. +type PricingProps = Omit & { + userId: string; +}; + +type PricingTier = { + id: "free" | "pro" | "business"; + name: string; + price: number; + blurb: string; + features: string[]; + featured?: boolean; + ctaLabel: string; + // "checkout" = paid tier with an LS hosted checkout URL. + // "signin" = free tier; clicking opens the same Google sign-in + // flow the hero CTA uses. + // "mailto" = Business tier; static anchor to email. + ctaKind: "signin" | "checkout" | "mailto"; + ctaHref?: string; + // Which LS variant this tier maps to in the hosted-checkout URL. + // Only populated for `ctaKind: "checkout"` tiers. + checkoutTier?: "pro" | "business"; +}; + +const PRICING_TIERS: PricingTier[] = [ + { + id: "free", + name: "Free", + price: 0, + blurb: "Get a feel for the workbench on a few applications.", + ctaLabel: "Start free", + ctaKind: "signin", + features: [ + "3 tailored applications / month", + "20 assistant chat turns / month", + "50 job searches / month", + "5 saved jobs", + "PDF export, ATS theme", + ], + }, + { + id: "pro", + name: "Pro", + price: 9, + blurb: "For active job seekers running multiple tailored applications a week.", + featured: true, + ctaLabel: "Get Pro", + ctaKind: "checkout", + checkoutTier: "pro", + features: [ + "20 tailored applications / month", + "5 premium applications with GPT-5.5", + "Unlimited job searches + saved jobs", + "150 assistant chat turns / month", + "PDF + DOCX export, all themes", + "30-day workspace history", + ], + }, + { + id: "business", + name: "Business", + price: 39, + blurb: "Career coaches and recruiting teams. Billed per seat, per month.", + ctaLabel: "Get Business", + ctaKind: "checkout", + checkoutTier: "business", + // Fallback mailto: when LS isn't configured for Business yet, + // the Business CTA falls back to this href via the + // isLemonSqueezyEnabled() branch in PricingSection. + ctaHref: + "mailto:antony.leander@gmail.com?subject=Job%20Application%20Copilot%20%E2%80%94%20Business%20tier", + features: [ + "Everything in Pro", + "80 tailored applications / seat", + "25 premium applications with GPT-5.5", + "500 assistant chat turns / seat", + "SSO, admin dashboard, shared shortlists", + "Unlimited history, no retention TTL", + "Priority email support", + ], + }, +]; + +function PricingCheck() { + // Inline SVG so we don't import an icon set just for one section. + // Stroke weight matches the rest of the landing. + return ( + + ); +} + +function PricingSection({ + authStatus, + isSignedIn, + pendingAction, + onPrimaryCta, + anyActionPending, + userId, +}: PricingProps) { + // The CTA disabled state is the same as the hero + final CTA so it + // greys out during restoring/redirecting. The Business mailto + // fallback is a plain and is always + // clickable -- mailto doesn't get caught by primaryDisabled. + const primaryDisabled = anyActionPending || authStatus === "restoring"; + const lsEnabled = isLemonSqueezyEnabled(); + void pendingAction; + + // Resolve the per-tier click handler at render time so the JSX + // below stays declarative. The handler dispatches on: + // * tier.ctaKind = "signin" -> auth flow (Free). + // * tier.ctaKind = "checkout" -> if LS is configured AND the + // user is signed in, navigate to + // the hosted checkout URL with + // user_id binding. If LS is + // configured but the user isn't + // signed in, run auth first; the + // user lands back here and can + // re-click. If LS is NOT + // configured, fall through to the + // mailto fallback for Business or + // disable the button for Pro. + // * tier.ctaKind = "mailto" -> plain mailto (rendered as ). + function buildCtaLabel(tier: PricingTier): string { + if (tier.ctaKind === "checkout" && !lsEnabled) { + return tier.checkoutTier === "business" && tier.ctaHref + ? "Contact us" + : "Coming soon"; + } + return tier.ctaLabel; + } + + // Onclick body for ctaKind="checkout" anchors. Inlined as a + // returned thunk so React infers the event type from the JSX + // attribute (matches the other onClick handlers in this file). + function buildCheckoutClickHandler(tier: PricingTier) { + return (event: { preventDefault: () => void }) => { + event.preventDefault(); + if (primaryDisabled) return; + if (!tier.checkoutTier) return; + // Not signed in yet -> run auth first. The user lands back + // on the landing page; clicking Get Pro again takes them + // straight to the LS hosted checkout with the right user_id + // binding. + if (!isSignedIn || !userId) { + onPrimaryCta(); + return; + } + const url = getCheckoutUrl(tier.checkoutTier, userId); + if (!url) return; + // window.location.assign to keep the back button useful + // (a post-checkout return navigates back here). + window.location.assign(url); + }; + } + + return ( +
+
+ Pricing +

+ Start free, upgrade when you need more +

+
+
+ {PRICING_TIERS.map((tier) => { + const isFeatured = Boolean(tier.featured); + // Business falls back to mailto when LS isn't configured + // (the mailto fallback is always available because of + // tier.ctaHref). Pro has no mailto fallback and renders + // as a disabled "Coming soon" button. + const fallbackToMailto = + tier.ctaKind === "checkout" && + !lsEnabled && + Boolean(tier.ctaHref); + const renderAsDisabled = + tier.ctaKind === "checkout" && !lsEnabled && !tier.ctaHref; + const ctaLabel = buildCtaLabel(tier); + return ( +
+ {isFeatured ? ( + + Most popular + + ) : null} +
+

{tier.name}

+

{tier.blurb}

+
+

+ ${tier.price} + /month +

+ {/* Chrome 148+ in dark color-scheme refuses to honor + background-color on
+ ); + })} +
+
+ ); +} + +// ─── Final CTA ──────────────────────────────────────────────────────── + +type FinalCtaProps = Omit; + +function FinalCtaSection({ + authStatus, + isSignedIn, + pendingAction, + onPrimaryCta, + anyActionPending, +}: FinalCtaProps) { + const ctaLabel = isSignedIn + ? pendingAction === "handoff" + ? "Opening workspace…" + : "Enter workspace" + : authStatus === "restoring" + ? "Restoring session…" + : pendingAction === "signin" + ? "Redirecting…" + : "Sign in with Google"; + + return ( +
+
+ {/* "Ready to tailor?" promoted from a tiny eyebrow to the + primary headline so it visually carries weight against the + big primary button below. The earlier sub line ("One + workspace from raw resume to…") was removed — it just + repeated what the hero already said. */} +

Ready to tailor?

+
+ +
+
+
+ ); +} + +// ─── Footer ─────────────────────────────────────────────────────────── + +function LandingFooter() { + return ( +
+
+
+

Job Application Copilot

+

+ A focused workspace for preparing stronger applications from one + place. +

+

Built by Leander Antony A

+
+ +
+
+

Navigation

+ + Privacy Policy + +
+ +
+
+
+ ); +} + +// ─── Inline icon glyphs ─────────────────────────────────────────────── + +function GoogleGlyph() { + return ( + + + + + + + ); +} + +// Animated hamburger ↔ close glyph. Three horizontal bars when closed +// (the conventional hamburger), rotated into an X when open. Pure +// inline SVG so it inherits the button's `currentColor` and respects +// the same theming pipeline as every other glyph on the page. +function BurgerGlyph({ open }: { open: boolean }) { + return ( + + {open ? ( + <> + + + + ) : ( + <> + + + + + )} + + ); +} + +function GitHubGlyph() { + return ( + + + + ); +} diff --git a/frontend/src/components/workspace/AnalysisRunner.tsx b/frontend/src/components/workspace/AnalysisRunner.tsx new file mode 100644 index 0000000..10d8e3e --- /dev/null +++ b/frontend/src/components/workspace/AnalysisRunner.tsx @@ -0,0 +1,351 @@ +"use client"; + +// Workflow run + progress card — Direction B redesign. +// +// Behavior preservation: +// - "Run analysis" disabled until parent reports `ready` (resume + JD) +// - useAnalysisJob polling drives `currentWorkflowStage`; pipeline +// reflects that. The `analysisJobState.progress_percent` overrides +// the stage's static value when present. +// - Stale notice + Clear-role action retained. +// +// Layout (per handoff specs/04-analysis.md): +// 1. Region head (title + STEP 04 tag) +// 2. b-run-bar — status pip, Re-run / Clear actions +// 3. b-pipeline — multi-column stage cards with active glow +// +// The ArtifactViewer that renders below this component on the same tab +// is its own sibling component — no change here. + +import type { + WorkspaceAnalysisJobStatusResponse, + WorkspaceAnalysisResponse, + WorkspaceQuotaResponse, +} from "@/lib/api-types"; +import { PlayIcon } from "@/components/workspace/icons"; + +export type WorkflowStage = { + title: string; + detail: string; + value: number; +}; + +export type AnalysisRunnerProps = { + analysisState: WorkspaceAnalysisResponse | null; + analysisLoading: boolean; + analysisJobState: WorkspaceAnalysisJobStatusResponse | null; + analysisIsStale: boolean; + currentWorkflowStage: WorkflowStage | null; + onRunAnalysis: () => void; + onClearRole: () => void; + /** True when both a resume + JD are present. */ + ready: boolean; + /** Quota snapshot from GET /workspace/quota. Null when anonymous, + * auth restoring, or the fetch failed — the toggle renders in the + * disabled state in that case (the safe default). */ + quota: WorkspaceQuotaResponse | null; + /** Premium toggle state. True = user wants to run this workflow on + * gpt-5.5 (Pro+/Business only). Free tier never reaches this with + * premium=true because the toggle is disabled when + * `quota.premium_available` is false. */ + premium: boolean; + /** Setter for the toggle. The parent (WorkspaceShell) owns the + * state because both this component and the run-hook need to read + * it. */ + onPremiumChange: (next: boolean) => void; +}; + +// Pipeline stages shown in the redesigned layout. +// +// Each stage carries: +// - `key`: backend `stage_title` (must match useAnalysisJob's +// AGENTIC_WORKFLOW_STAGES so we can locate the live stage). +// - `displayTitle`: user-facing action label, e.g. "Drafting +// tailored resume". This is the primary text the user sees. +// - `agentLabel`: which background agent owns this step. Surfaced +// as a small mono badge so users understand WHO is doing it, +// without needing to know what a "Forge agent" is up front. +// +// Progress values still come from `currentWorkflowStage` + +// `analysisJobState`; this list only controls order + labels. +type PipelineStageDef = { + key: string; + displayTitle: string; + agentLabel: string; +}; +const PIPELINE_STAGES: PipelineStageDef[] = [ + { + key: "Workflow crew", + displayTitle: "Reading inputs", + agentLabel: "Workflow crew", + }, + { + key: "Matchmaker agent", + displayTitle: "Scoring role fit", + agentLabel: "Matchmaker agent", + }, + { + key: "Forge agent", + displayTitle: "Drafting tailored resume", + agentLabel: "Forge agent", + }, + { + key: "Gatekeeper agent", + displayTitle: "Reviewing outputs", + agentLabel: "Gatekeeper agent", + }, + { + key: "Builder agent", + displayTitle: "Final assembly", + agentLabel: "Builder agent", + }, + { + key: "Cover letter agent", + displayTitle: "Drafting cover letter", + agentLabel: "Cover letter agent", + }, +]; + +export function AnalysisRunner({ + analysisState, + analysisLoading, + analysisJobState, + analysisIsStale, + currentWorkflowStage, + onRunAnalysis, + onClearRole, + ready, + quota, + premium, + onPremiumChange, +}: AnalysisRunnerProps) { + // Premium toggle gating logic. + // * Disabled when quota is null (anonymous / restoring / fetch + // failed) — the safe default. + // * Disabled when `quota.premium_available` is false (Free tier). + // * Disabled while a workflow is running — no point letting the + // user flip mid-run. + // + // The tooltip copy adapts: when premium isn't available we point + // at the upgrade page; when it IS available we surface the + // remaining credits. + const premiumAvailable = Boolean(quota?.premium_available); + const premiumCounter = quota?.counters.premium_applications ?? null; + const premiumRemaining = premiumCounter?.remaining ?? 0; + const premiumLimit = premiumCounter?.limit ?? 0; + const premiumOutOfCredits = + premiumAvailable && premiumLimit > 0 && premiumRemaining <= 0; + const premiumDisabled = + !premiumAvailable || analysisLoading || premiumOutOfCredits; + const premiumTooltip = !premiumAvailable + ? "Upgrade to Pro for premium AI (GPT-5.5)" + : premiumOutOfCredits + ? "You've used all your premium credits this month" + : `${premiumRemaining} of ${premiumLimit} premium credits left this month`; + const liveStageTitle = currentWorkflowStage?.title ?? null; + const livePercent = + analysisJobState?.progress_percent ?? currentWorkflowStage?.value ?? null; + + // Build the pipeline view: each stage has a state (done/active/next) + // and a value. We mark stages BEFORE the live one as done, the live + // one as active w/ the live percent, and stages AFTER as next. + // After analysis completes, every stage ticks to done. + const liveIndex = liveStageTitle + ? PIPELINE_STAGES.findIndex((stage) => stage.key === liveStageTitle) + : -1; + + const stages = PIPELINE_STAGES.map((stage, index) => { + let state: "done" | "active" | "next" = "next"; + let value = 0; + let detail = ""; + + if (analysisState) { + state = "done"; + value = 100; + } else if (analysisLoading) { + if (liveIndex >= 0) { + if (index < liveIndex) { + state = "done"; + value = 100; + } else if (index === liveIndex) { + state = "active"; + value = livePercent ?? 50; + detail = currentWorkflowStage?.detail ?? ""; + } + } else if (index === 0) { + state = "active"; + value = livePercent ?? 25; + detail = "Coordinating agents"; + } + } + return { ...stage, state, value, detail }; + }); + + return ( +
+
+
+
Workflow run
+
+ {analysisState + ? `${analysisState.workflow.mode} · ${ + analysisState.workflow.review_approved + ? "review approved" + : "review pending" + }` + : analysisLoading + ? "Generating tailored documents…" + : ready + ? "Ready to run — both inputs are loaded." + : "Need a parsed resume + JD to run."} +
+
+ STEP 04 +
+ +
+
+ + {analysisState + ? "Outputs ready" + : analysisLoading + ? "Running…" + : ready + ? "Idle" + : "Inputs needed"} + + {currentWorkflowStage && analysisLoading ? ( + + {currentWorkflowStage.title} · {analysisJobState?.stage_detail ?? + currentWorkflowStage.detail} + + ) : null} +
+
+ + + +
+
+ + {analysisIsStale ? ( +
+ The inputs changed after the last run. Re-run the workflow to refresh + your documents. +
+ ) : null} + +
+ {stages.map((stage) => ( +
+
+ + {stage.displayTitle} + + + {Math.round(stage.value)}% + +
+
+ + {stage.agentLabel} + + + {stage.state === "done" + ? "complete" + : stage.state === "active" + ? "running" + : "standby"} + +
+
+ {stage.state === "active" && stage.detail + ? stage.detail + : stage.state === "done" + ? "All done — output committed." + : "Waiting its turn."} +
+ +
+ ))} +
+ + {/* Mobile-only: when every agent has finished, the 6 "All done" + cards add nothing — collapse them to a single confirmation + line. Hidden on desktop via CSS. The pipeline cards + themselves are also hidden on mobile in the idle / all-done + states (see globals.css mobile pass). */} + {analysisState ? ( +
+ + + All {PIPELINE_STAGES.length} agents finished — your tailored + documents are ready below. + +
+ ) : null} + + {!analysisState && !analysisLoading ? ( +
+
Once the workflow runs
+
+ {ready + ? "Press Run analysis above to unlock your tailored resume + cover letter. Each agent posts its progress here as it works." + : "Add a parsed resume and a job description before running the analysis. The Documents section below will fill in once the workflow completes."} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/workspace/ArtifactViewer.tsx b/frontend/src/components/workspace/ArtifactViewer.tsx new file mode 100644 index 0000000..5552bcb --- /dev/null +++ b/frontend/src/components/workspace/ArtifactViewer.tsx @@ -0,0 +1,221 @@ +"use client"; + +// Artifact viewer (Resume / Cover Letter tabs + preview iframe + export +// buttons) — Direction B redesign. +// +// Behavior preservation: +// - Tabs: Tailored Resume | Cover Letter (parent owns artifactTab) +// - PDF / DOCX download buttons → onExport(kind, format) +// - Server-rendered preview iframe via previewHtml +// - Loading + missing-preview fallbacks retained +// +// Layout (per handoff specs/04-analysis.md): +// - b-artifact-tabs — pill row above the doc +// - b-artifact-body — 2-col grid: doc body (iframe) + right-rail +// - b-artifact-aside — title, summary, download buttons, meta line + +import type { ArtifactTheme, WorkspaceArtifactKind } from "@/lib/api-types"; + +export type ArtifactTab = "resume" | "cover-letter"; + +// DOCX replaces markdown export; markdown is no longer a download +// option (the artifact's `markdown` content field is still used for +// the in-app preview, but it is not surfaced as a download). +export type ArtifactExportFormat = "pdf" | "docx"; + +export type ArtifactViewerArtifact = { + title: string; + summary: string; +}; + +const THEME_OPTIONS: { value: ArtifactTheme; label: string }[] = [ + { value: "classic_ats", label: "Default" }, + { value: "professional_neutral", label: "Neutral" }, +]; + +const THEME_HINT: Record = { + classic_ats: + "Warm cream paper, brown accents — distinctive, design-forward. Good for startups, design-eng, modern tech.", + professional_neutral: + "Pure black on white, no color. Conservative; safer for Big Tech recruiting at scale, banks, defense, or B&W printing.", +}; + +const TAB_LABELS: Record = { + resume: "Tailored Resume", + "cover-letter": "Cover Letter", +}; + +const TAB_ORDER: ArtifactTab[] = ["resume", "cover-letter"]; + +function kindForTab( + tab: ArtifactTab, +): WorkspaceArtifactKind { + return tab === "resume" ? "tailored_resume" : "cover_letter"; +} + +export type ArtifactViewerProps = { + /** Whether a workspace analysis run has produced artifacts. */ + hasAnalysis: boolean; + /** Currently-selected artifact (title + summary). `null` while loading. */ + artifact: ArtifactViewerArtifact | null; + tab: ArtifactTab; + onTabChange: (tab: ArtifactTab) => void; + /** + * Key of the export currently in flight, formatted as + * `${artifactKind}:${exportFormat}` (e.g. `"tailored_resume:pdf"`). + * `null` when no export is in progress. + */ + exporting: string | null; + previewHtml: string | null; + previewTitle: string | null; + previewLoading: boolean; + /** + * Theme of the currently-active artifact (resume theme when on the + * resume tab, cover-letter theme when on the cover letter tab). The + * picker writes back via onThemeChange. + */ + activeTheme: ArtifactTheme; + onThemeChange: (theme: ArtifactTheme) => void; + onExport: (kind: WorkspaceArtifactKind, format: ArtifactExportFormat) => void; +}; + +export function ArtifactViewer({ + hasAnalysis, + artifact, + tab, + onTabChange, + exporting, + previewHtml, + previewTitle, + previewLoading, + activeTheme, + onThemeChange, + onExport, +}: ArtifactViewerProps) { + const artifactKind = kindForTab(tab); + + if (!hasAnalysis) { + return ( +
+
+
+
Documents
+
+ The tailored resume and cover letter appear here after the run. +
+
+
+
+ Run the workflow first — artifact previews and downloads unlock once + the analysis completes. +
+
+ ); + } + + return ( +
+
+
+
Documents
+
+ Review and download your tailored package. +
+
+
+ +
+ {TAB_ORDER.map((value) => ( + + ))} +
+ +
+
+ {previewLoading ? ( +
+ Preparing the artifact preview… +
+ ) : previewHtml ? ( +