Compare commits

...

87 Commits

Author SHA1 Message Date
Daniel Alves Rösel
1e57ec7642 Merge pull request #12 from velocitatem/claude/webapp-robustness-diff-propagation-433ive
Add patch propagation and improve auth security
2026-06-19 19:07:02 +02:00
Claude
52eab5b6e4 feat: robust login with rate limiting + diff propagation across tree
Login hardening:
- API: in-memory rate limiter (10 attempts / IP / 15 min), timing-safe credential comparison, strict body validation
- Middleware: validate session token timestamp expiry (7-day window) not just HMAC signature
- Logout: also clears oidc_token_pub cookie
- Login page: try/catch around fetch to surface network errors, parse OIDC error query params, disable inputs during loading, wrap useSearchParams in Suspense

Diff propagation:
- DiffViewer: shows inherited ancestor patches in a collapsible section above own patches
- CVTree: annotates each branch with ↑N ancestor patch count badge; propagate (↓) button on hover for versions with own patches and child branches
- Dashboard: computeInheritedPatches/getDescendants helpers; Propagate ↓ action button in version header; handlePropagate sends current version's patches to all descendants via appendPatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w
2026-06-19 11:00:41 +00:00
Daniel Alves Rösel
f625dead76 Merge pull request #11 from velocitatem/claude/improve-docs-preview-94gyn
Add PDF preview and DOCX upload to version branches
2026-05-03 01:34:13 +04:00
Claude
97ee914b1b feat: live PDF preview, upload-to-branch diff, and copy markdown per branch
- Backend: authenticated GET preview endpoint generates PDF on-demand from any
  version without requiring a public asset publish
- Backend: POST upload endpoint on a version accepts a .docx, parses it to
  structured blocks, diffs against current blocks, and records patches
- Frontend: new Preview tab shows live PDF rendered from the authenticated
  endpoint (blob URL via fetch with auth header)
- Frontend: Upload DOCX (arrow-up) button in action bar sends the file to the
  branch, backend computes diff automatically
- Frontend: Copy Markdown button (clipboard icon) appears on branch hover in
  CVTree; copies block path/type/text as structured markdown to clipboard

https://claude.ai/code/session_01BTNfDfgFvcnehkve6r66nk
2026-05-02 21:31:49 +00:00
a21f14ea87 hotfix: internal server rror feedback 2026-04-18 12:42:10 +02:00
Daniel Alves Rösel
61430317f4 Merge pull request #10 from velocitatem/copilot/remove-cv-alves-world-references
Remove cv.alves.world references from README
2026-04-08 00:43:55 +04:00
copilot-swe-agent[bot]
03187ab456 Remove cv.alves.world references from README
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/96afd90d-596d-4afd-a366-a95782f198f4

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-07 20:37:34 +00:00
Daniel Alves Rösel
9b5add1cdf Merge pull request #9 from velocitatem/copilot/remove-maximum-patchsize
Remove max patchsize limit from validate_patchset
2026-04-07 14:18:36 +04:00
copilot-swe-agent[bot]
3838bfe51a remove max patchsize limit from validate_patchset
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/e5483685-0807-4655-ba29-68311e6403db

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-07 10:08:56 +00:00
Daniel Alves Rösel
9c57dcaedb Skip dashboard auth middleware in demo mode 2026-04-05 16:36:49 +04:00
Claude
5680465e98 Skip login redirect in demo mode
When NEXT_PUBLIC_DEMO=true, bypass session/OIDC checks and allow direct dashboard access.

https://claude.ai/code/session_01PD5xMUxpkDEdfTAGZ2J4Yr
2026-04-05 12:20:17 +00:00
41352b05ec chore: upgrade Next.js to 15.5.14 for security 2026-04-05 13:44:23 +02:00
04882b2652 fix: remove unused dashboard demo timestamp constant 2026-04-05 13:41:46 +02:00
Daniel Alves Rösel
6ab7e35a6a Merge pull request #8 from velocitatem/claude/nlp-insights-demo-mode-iG1lv
feat: NLP patch insights + standalone demo mode
2026-04-05 15:27:36 +04:00
Claude
ba4fedd1fa chore: update uv.lock after dependency resolution
https://claude.ai/code/session_01LWxu2qrwY6BRjUFXXn7NiM
2026-04-05 09:34:23 +00:00
Claude
615d1bdb9e feat: NLP patch insights + standalone demo mode
- dlib/ai/insights.py: pure-Python NLP analysis that correlates accepted
  AI suggestion operations/keywords/sections with submission outcomes
  (pending_review / published = positive, archived = negative)
- Backend: GET /api/v1/insights route + service + Pydantic schema
- Frontend: InsightsPanel component with bar charts for operation impact,
  section impact, and keyword signal lift scores
- Insights tab added to the version panel; compact preview on doc overview
- NEXT_PUBLIC_DEMO=true makes the webapp fully standalone: loads
  DEMO_DOCUMENTS / DEMO_SUBMISSIONS / DEMO_INSIGHTS from demo-data.ts,
  disables all mutating actions, shows a DEMO badge in the top bar
- apps/webapp/public/demo-cv.docx: static dummy CV (Alex Rivera) for demo
- scripts/gen_demo_cv.py: script to regenerate the demo DOCX
- .env.example: document NEXT_PUBLIC_DEMO flag

https://claude.ai/code/session_01LWxu2qrwY6BRjUFXXn7NiM
2026-04-05 09:34:01 +00:00
0f32d46404 fix: eager-load submission relations in create/update responses 2026-04-04 15:49:45 +02:00
2f112efe75 fix: cascade delete dependent rows for version removal 2026-04-04 15:46:53 +02:00
2de579ea59 fix: show dashboard overview only on branch selection screen 2026-04-04 15:40:19 +02:00
5facc4b7a5 feat: add dashboard success-rate tracking and submission stages 2026-04-04 15:16:12 +02:00
07fbfbbd85 docs: strengthen README positioning and feature pitch 2026-04-04 15:00:48 +02:00
3f0ac81357 docs: improve system architecture diagram 2026-04-04 14:55:56 +02:00
747c129f0b docs: replace README sketch with mermaid tree 2026-04-04 14:52:59 +02:00
3eb2825a86 fix: accept non-JSON CORS origins env values 2026-04-04 14:48:06 +02:00
Daniel Alves Rösel
ca6e25bb31 Merge pull request #7 from velocitatem/claude/public-ready-setup-n0Sbb
Prepare repository for public deployment
2026-04-04 14:19:55 +04:00
Claude
aa419cde0d Prepare repository for public deployment
- Replace ReportLab PDF export with LibreOffice headless for proper DOCX formatting preservation
- Add libreoffice-writer + fonts-liberation to backend Dockerfile
- Proxy public CV PDFs through frontend (/cv/[slug]) instead of redirecting to MinIO storage directly
- Fix docker-compose: route backend/worker to internal MinIO URL (http://cvfs-minio:9000), remove MinIO from public network, parameterize all domain/env vars
- Add storage cleanup (MinIO artifact deletion) when a document is deleted
- Add docker-compose.standalone.yml for self-deployment without Traefik/dokploy
- Update .env.example with comprehensive self-deployment documentation

https://claude.ai/code/session_017HGM9VPptZG52asT5pbL6Y
2026-04-04 10:06:20 +00:00
96a1f1683a feat: surface published assets for versions 2026-04-04 11:43:31 +02:00
d126315fa5 docs: rewrite readme for resume branches 2026-04-04 11:36:15 +02:00
c9914191d8 feat: allow updating existing CV branches 2026-04-04 11:29:46 +02:00
15d5ef6ac6 fix: cascade public assets on version deletion 2026-04-04 11:25:50 +02:00
95c81955c9 fix: remove public assets before deleting branch 2026-04-04 11:23:48 +02:00
3b490dedfc fix: pass API_BASE_URL into web build 2026-04-04 11:21:49 +02:00
8e74f85178 fix: include reportlab in backend requirements 2026-04-04 11:16:41 +02:00
Daniel Alves Rösel
700ff5ee0d Merge pull request #6 from velocitatem/claude/fix-document-loading-backend-PVFi6
Fix document loading: circular FK, patch validation 500, and token expiry redirect
2026-04-04 11:49:05 +04:00
Claude
bdf9b25544 chore: ignore *.db files (local SQLite test databases)
https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:32 +00:00
Claude
1b11cdf25c Fix document loading: circular FK, patch validation 500, and token expiry redirect
- documents.py: fix root_version_id never being saved due to SQLAlchemy
  deferring the circular FK (CvDocument↔CvVersion). Use flush-based approach:
  flush doc, flush version, then set root_version_id before final commit.
- config.py: add validator to coerce empty MINIO_ENDPOINT string to None,
  preventing boto3 ValueError: Invalid endpoint at startup.
- versions.py: catch PatchValidationError and return 422 instead of 500
  when a patch violates ATS guard rules.
- api.ts: on 401, clear stale OIDC cookies and redirect to /login instead
  of showing the "Failed to load documents" error.

https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:00 +00:00
Daniel Alves Rösel
1a261be792 Merge pull request #5 from velocitatem/copilot/fix-404-page-sidebar-issue
fix: "404 page not found" in dashboard sidebar after login
2026-04-04 10:48:26 +04:00
copilot-swe-agent[bot]
0b38f9f703 fix: correct default API_BASE_URL and improve sidebar error message
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/1a9aa3e0-c4e1-48fb-b646-49f31c316325

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:35:18 +00:00
copilot-swe-agent[bot]
8bc501fa85 Initial plan 2026-04-04 06:19:11 +00:00
Daniel Alves Rösel
b18ad7712c Merge pull request #4 from velocitatem/copilot/add-pdf-rendering-and-analytics
feat: CV share analytics + PDF rendering for public links
2026-04-04 10:05:35 +04:00
copilot-swe-agent[bot]
ad91369371 fix: address code review - use timezone-aware datetime, full ip hash
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:02:24 +00:00
copilot-swe-agent[bot]
7435a0f1bf feat: add CV view analytics and PDF rendering for public share links
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 05:59:05 +00:00
copilot-swe-agent[bot]
b63417b8b3 Initial plan 2026-04-04 05:50:23 +00:00
66bf016747 verify tokens using full jwks and relaxed options 2026-04-03 19:51:48 +02:00
dfc3764bcc normalize discovery issuer path 2026-04-03 19:45:20 +02:00
b5053c5536 strip authorize suffix from issuer 2026-04-03 19:37:49 +02:00
d2ad0c3fdd use raw issuer for discovery 2026-04-03 19:36:22 +02:00
effb9161f8 use kid-specific jwk for verification 2026-04-03 19:33:56 +02:00
fa215009cd allow jwt alg from token header 2026-04-03 19:31:23 +02:00
e7bac3b178 discover jwks uri from oidc configuration 2026-04-03 19:28:03 +02:00
ba0612efb8 parse authentik issuer path correctly 2026-04-03 19:24:54 +02:00
7e5f2bb06a fix authentik issuer normalization 2026-04-03 19:22:51 +02:00
5a8e8f1572 force authentik issuer base 2026-04-03 19:21:13 +02:00
9f90b000e2 normalize oidc issuer for authentik 2026-04-03 19:18:27 +02:00
dce592c086 redirect using public base url 2026-04-03 19:15:37 +02:00
531c27b669 use authentik host for authorize urls 2026-04-03 19:13:28 +02:00
81165ca9db normalize authentik issuer paths 2026-04-03 19:04:43 +02:00
3f6b9a4f81 propagate authentik env into web build 2026-04-03 19:01:17 +02:00
dcfe207389 wire authentik env vars in compose 2026-04-03 18:47:42 +02:00
5ccae82cfd load env file for compose services 2026-04-03 18:45:32 +02:00
af7a9cb63f fix cascade delete on cv versions 2026-04-03 18:28:54 +02:00
Daniel Alves Rösel
d6e5e563f1 Merge pull request #3 from velocitatem/copilot/add-delete-buttons-and-fix-header
Rename to cvfs, remove dashboard heading, add branch delete buttons
2026-04-03 19:17:40 +04:00
copilot-swe-agent[bot]
77d454cf09 rename to cvfs, remove dashboard heading, add branch delete buttons
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/2bd56e04-d1e0-4e38-93b6-a99afc1d2b3c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-03 15:08:05 +00:00
copilot-swe-agent[bot]
7543402c83 Initial plan 2026-04-03 15:03:54 +00:00
Daniel Alves Rösel
83b609f815 Merge pull request #2 from velocitatem/copilot/add-mobile-support-and-deleting-functions
Add mobile support, CV/branch deletion, and fix DOCX export to include patches
2026-04-03 18:54:47 +04:00
copilot-swe-agent[bot]
5d815cd24d feat: add mobile support, delete CV/branch, and fix DOCX export with patches
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/4d754ed6-7f63-44e0-8689-123d7a70595f

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-03 14:45:54 +00:00
copilot-swe-agent[bot]
300a577fbe Initial plan 2026-04-03 14:31:39 +00:00
Daniel Alves Rösel
2002a78509 Merge pull request #1 from velocitatem/claude/cv-branching-dashboard-XGi60
fix(webapp): use Web Crypto API in middleware and drop node: prefix in auth route
2026-04-03 18:09:34 +04:00
Claude
8d72dfa09d fix(webapp): use Web Crypto API in middleware and drop node: prefix in auth route
Middleware runs in Edge Runtime (no Node.js built-ins), so use
globalThis.crypto.subtle for HMAC verification. Route handler uses
`import { createHmac } from 'crypto'` without the node: prefix
which webpack cannot resolve during Next.js build.

https://claude.ai/code/session_01CdisLhbC2kVt2hxfJ7TNPf
2026-04-03 13:59:09 +00:00
Daniel Alves Rösel
684936ab72 Add AI-powered CV tailoring, submissions tracking, and auth overhaul 2026-04-03 17:57:28 +04:00
Claude
01f34915f6 feat(dashboard): complete CV branching dashboard with auth and full editing workflow
- Visual branch heritage tree with colored dots and connecting lines, depth-aware expand/collapse
- Dashboard 3-tab layout: Content (inline block editing + patch staging), Patches (diff view), Submissions (AI suggestions)
- Inline block editing: click to edit any CV block, stage edits, save as named branch with pre-filled patches
- Submissions tab: create applications, request AI tailoring suggestions, accept/reject per suggestion
- Simple hardcoded login (username/password via env vars LOGIN_USER/LOGIN_PASS, defaults admin/admin)
- Authentik OIDC integration: authorize redirect + callback exchange, configurable via NEXT_PUBLIC_AUTHENTIK_*
- Middleware protecting /dashboard with session cookie verification (HMAC-SHA256)
- Auth API routes: /api/auth/login, /api/auth/logout, /api/auth/callback, /api/auth/token
- Backend: GET/PATCH submission routes for listing submissions and accepting/rejecting AI suggestions
- API client: OIDC bearer token forwarding from client-readable cookie

https://claude.ai/code/session_01CdisLhbC2kVt2hxfJ7TNPf
2026-04-03 13:45:51 +00:00
9a8add0bcd feat(webapp): add public CV viewer redirect route 2026-04-02 21:11:54 +02:00
abf424779d fix(db): ensure patches are eager-loaded when creating a new branch 2026-04-02 21:06:45 +02:00
3ebe9d3fb8 fix(db): resolve ambiguous foreign key relationships in join queries 2026-04-02 21:04:24 +02:00
1ff7c5b23a fix(db): specify ON conditions in publication queries to avoid ambiguous foreign keys 2026-04-02 20:57:07 +02:00
0d1020e503 fix(db): ensure versions and patches are loaded when returning created document 2026-04-02 20:55:59 +02:00
28678ab17f fix(db): specify foreign keys for CvDocument.versions relationship to resolve ambiguity 2026-04-02 20:53:53 +02:00
93f9e88fc4 fix(api): fix trailing slash proxy redirects 2026-04-02 20:50:55 +02:00
bd116ff247 fix(docker): correct minio environment variable format 2026-04-02 20:47:53 +02:00
02a682b401 fix(networking): update MINIO_ENDPOINT to use public URL and allow trailing slashes in FastAPI 2026-04-02 20:46:19 +02:00
Daniel Alves Rösel
f1a85da721 Disable automatic trailing slash redirect in FastAPI 2026-04-02 22:35:45 +04:00
Daniel Alves Rösel
fe820c7969 Update default API backend URL to production endpoint 2026-04-02 22:32:23 +04:00
Daniel Alves Rösel
6fe9c84034 Configure backend API endpoint for Docker deployments 2026-04-02 22:30:03 +04:00
Daniel Alves Rösel
72f9993261 Remove API_BASE_URL constant and use relative API paths 2026-04-02 22:26:30 +04:00
Daniel Alves Rösel
3f00bf209f Move API base URL to server-side configuration via Next.js rewrites 2026-04-02 22:24:46 +04:00
Daniel Alves Rösel
71b6388660 Refactor: Remove unused status class mapping and use Next.js Link 2026-04-02 22:17:06 +04:00
Daniel Alves Rösel
62c8d355ca Replace mock data with real API integration in dashboard 2026-04-02 22:14:44 +04:00
59 changed files with 4313 additions and 962 deletions

View File

@@ -1,66 +1,63 @@
NAME=myproject
COMPOSE_PROJECT_NAME=$NAME
# Resume Branches — environment configuration
# Copy this file to .env and fill in values before running docker compose.
# For standalone (no Traefik): docker compose -f docker-compose.standalone.yml up -d
# For Traefik-based production: docker compose up -d (edit Traefik labels in docker-compose.yml)
# Backend
BACKEND_MODE=fastapi
BACKEND_PORT=9812
# ── General ───────────────────────────────────────────────────────────────────
NAME=cvfs
COMPOSE_PROJECT_NAME=cvfs
# ── Public URLs ───────────────────────────────────────────────────────────────
# The URL users visit to access the app (no trailing slash).
# Standalone local: http://localhost:3000
# Production with a domain: https://cv.example.com
PUBLIC_BASE_URL=http://localhost:3000
# Domain used to construct published CV links (hostname only, no scheme).
CV_PUBLIC_DOMAIN=localhost
# ── Backend ───────────────────────────────────────────────────────────────────
BACKEND_PORT=8080
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
# Comma-separated list of allowed CORS origins
CORS_ORIGINS=http://localhost:3000
# Ports
REDIS_PORT=6378
GRAFANA_PORT=3125
LOKI_PORT=3142
# PostgreSQL
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=postgres
# ── PostgreSQL ────────────────────────────────────────────────────────────────
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
# MongoDB
MONGO_PORT=27017
MONGO_DB=app
MONGO_USER=admin
MONGO_PASSWORD=admin123
MONGO_HOST=localhost
# ── Redis ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379/0
DATABASE_TYPE=postgres
# Redis
REDIS_URL=redis://localhost:$REDIS_PORT
# Logging
LOGDIR="/tmp/logs-$NAME/"
# Supabase (webapp auth - set NEXT_PUBLIC_REQUIRE_AUTH=true to enable gating)
NEXT_PUBLIC_REQUIRE_AUTH=false
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_supabase_anon_key_here
# Server-side proxy target (read by next.config.ts at runtime, not baked into the bundle)
API_BASE_URL=http://localhost:9812
# MinIO Object Storage (used instead of S3)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
MINIO_ENDPOINT=http://localhost:9900
# ── MinIO object storage ──────────────────────────────────────────────────────
# Internal URL used by backend/worker (keep as-is for Docker deployments).
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=resume-branches
MINIO_REGION=us-east-1
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# MinIO admin console port (standalone mode only)
MINIO_CONSOLE_PORT=9001
# ML
ML_LATEST_WEIGHTS_PATH=/app/models/weights
MLFLOW_TRACKING_URI=http://localhost:5000
# ── Frontend port (standalone mode only) ─────────────────────────────────────
WEBAPP_PORT=3000
# AI / Agents
ANTHROPIC_API_KEY=sk-ant-...
# Auth / Publishing
PUBLIC_BASE_URL=https://cv.alves.world
CV_PUBLIC_DOMAIN=cv.alves.world
# ── Auth — OIDC (optional) ────────────────────────────────────────────────────
# Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication.
# Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.).
AUTH_DISABLE_VERIFICATION=true
# AUTH_OIDC_ISSUER=
# AUTH_OIDC_AUDIENCE=
# Optional: use Bedrock instead of direct Anthropic API
# CLAUDE_CODE_USE_BEDROCK=1
# Optional: use Vertex AI
# CLAUDE_CODE_USE_VERTEX=1
AUTH_OIDC_ISSUER=
AUTH_OIDC_AUDIENCE=
# Frontend OIDC config (baked into the Next.js build — requires rebuild on change)
NEXT_PUBLIC_AUTHENTIK_ISSUER=
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
# ── AI tailoring (optional) ───────────────────────────────────────────────────
# Leave blank to use the built-in rule-based tailoring instead of Claude.
ANTHROPIC_API_KEY=
# ── Demo mode ─────────────────────────────────────────────────────────────────
# Set to true to enable standalone demo mode in the webapp.
# Demo mode uses hardcoded dummy data — no backend or DB required.
NEXT_PUBLIC_DEMO=false

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ logs/
FLAT.xml
**/package-lock.json
.nx/
**/*.db

292
README.md
View File

@@ -1,166 +1,160 @@
# Ultiplate
# Resume Branches
A CV file system for tailoring resumes with version-control discipline: keep one canonical ATS-safe DOCX, branch role/company variants, review AI-suggested patches, and publish stable public links.
Template for any project: SaaS webapp, API server, ML pipeline, scraper, CLI, or background worker. AI-native, platform-agnostic, managed via Makefile + Nx.
![Type: CV File System](https://img.shields.io/badge/type-CV%20file%20system-0f766e)
![Frontend: Next.js](https://img.shields.io/badge/frontend-Next.js-111827)
![Backend: FastAPI](https://img.shields.io/badge/backend-FastAPI-059669)
[![License: MIT](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE)
## Quick Start
> Turn one master resume into a branching CV file system: specialization branches, role-specific submissions, and immutable public links without sacrificing ATS-safe formatting.
```bash
cp .env.example .env # fill in NAME and any keys you need
make init # uv venv + sync + env linking
make dev # Next.js webapp at http://localhost:3000
make nx.projects # list Nx projects in the monorepo
**Why this feels different:**
- Replace copy-paste resume sprawl with a single controlled source of truth.
- Review AI edits as explicit diffs, so every wording change stays auditable.
- Share stable published links while continuing to iterate privately.
`Quick start:` [Run locally](#quick-start) | [See architecture](#system-architecture) | [Explore workflows](#primary-workflows)
```mermaid
graph TD
ROOT[Root CV]
ROOT --> SPEC1[Specialization: ML Engineer]
ROOT --> SPEC2[Specialization: Product Engineer]
SPEC1 --> SUB1[Submission: Company A]
SPEC1 --> SUB2[Submission: Company B]
SPEC2 --> SUB3[Submission: Company C]
SUB1 --> PUB1[Published Public Link]
SUB2 --> PUB2[Published Public Link]
SUB3 --> PUB3[Published Public Link]
```
For Docker services (redis, ml inference, worker):
## Why Resume Branches exists
- **Preserve canonical formatting.** We never regenerate the DOCX wholesale; edits are stored as targeted patches on parsed blocks so ATS cues stay intact.
- **Branch with intent.** Root CV ➝ specializations ➝ submission leaves mirrors how people actually tailor resumes.
- **Controlled AI.** The worker produces constrained patch suggestions (max delta, no new claims) so you approve meaningful wording tweaks instead of rewriting history.
- **Publish safely.** Only frozen artifacts become public URLs; editing your working tree never breaks the links you already shared.
## Feature highlights
- **Keep one source of truth.** Start from a single ATS-safe DOCX and preserve structure while every change is tracked as targeted patches.
- **Branch your CV like code.** Spin up role and company variants in seconds without cloning files or losing lineage.
- **Tailor faster with guardrailed AI.** Generate constrained suggestions, review diffs, and accept only what improves relevance.
- **Ship polished versions confidently.** Publish immutable DOCX/PDF artifacts to stable links that never break after edits.
- **Run on production-ready primitives.** FastAPI, Postgres, MinIO, Redis, and Celery separate user flows from heavy background processing.
## Quick start
```bash
make up
cp .env.example .env # fill in MINIO_*, AUTH_*, etc.
make init # uv venv + deps + Bun installs + env linking
make lift.minio # optional: start local MinIO bucket
make run.backend # FastAPI API on http://localhost:8080
make dev # Next.js webapp on http://localhost:3000
```
## Directory
Need Redis/Postgres locally? `make lift.database` (Postgres) and `make up` (Redis + worker). For full stack via Docker Compose use `docker compose up backend webapp worker redis postgres minio`.
## System architecture
| Surface | Stack | Responsibilities |
|---------|-------|------------------|
| Webapp (`apps/webapp`) | Next.js 15 + React 19 + Tailwind 4 (Bun) | CV tree UI, inline editing, diff viewer, submissions dashboard, publish flows, public CV routes |
| Backend (`apps/backend/fastapi`) | FastAPI + SQLAlchemy + uvicorn | Auth/OIDC validation, DOCX ingestion, structured patch engine, branch CRUD, submissions API, publish service |
| Worker (`apps/worker`) | Celery + Redis | DOCX parsing, preview rendering, AI tailoring jobs, PDF export, artifact publication |
| Shared lib (`dlib`) | Python package | DOCX parser, block schema, patch application/validation, AI prompt helpers, storage adapters |
| Data plane | Postgres + MinIO + Redis | Metadata (documents/versions/patches/submissions), artifact storage (DOCX/PDF/HTML), queues + locks |
```mermaid
flowchart LR
U[User Browser]
W[Webapp\nNext.js]
API[FastAPI API]
WK[Worker\nCelery]
PG[(Postgres)]
RD[(Redis)]
MN[(MinIO / S3)]
U -->|HTTPS| W
W -->|REST + JWT/Cookie| API
API -->|CRUD metadata| PG
API -->|enqueue jobs| RD
API -->|store/fetch artifacts| MN
WK -->|consume jobs| RD
WK -->|read/write metadata| PG
WK -->|render + publish assets| MN
API -.->|status/events| W
```
## Data model snapshot
| Table | Purpose |
|-------|---------|
| `cv_documents` | Canonical resume root (owner, title, `root_version_id`). |
| `cv_versions` | Each branch node; references parent, artifact keys, structured blocks, metadata. |
| `cv_patches` | Stored diffs relative to parent version (`target_path`, operation, values, metadata). |
| `specializations` | Named reusable branch templates. |
| `submissions` | Role/company tailoring instances with status + AI suggestions. |
| `public_assets` | Immutable published artifacts (slug, version/submission, storage key, analytics). |
| `ai_suggestions` | Pending/accepted/rejected AI patch proposals tied to submissions. |
## Primary workflows
1. **Upload root CV.** Webapp calls `/documents` with a DOCX. Backend persists the original artifact to MinIO, parses structured blocks via `dlib.cv`, stores the first version, and returns the tree.
2. **Branch for a specialization.** Create `ml-engineer` from `root`. Backend clones structured blocks, applies staged patches, and records them in `cv_patches`.
3. **Tailor for a submission.** Attach job description, request AI suggestions, accept/reject patches, and either extend the branch in place or apply them to a leaf version.
4. **Publish.** `/public/publish` copies the artifact to a public bucket path, locks it, emits a slug URL, and tracks views via `public_asset_views`.
## Configuration essentials
| Variable | Description | Default |
|----------|-------------|---------|
| `API_BASE_URL` | Backend host consumed by the webapp build + runtime. | `http://cvfs-backend:8080` in Docker |
| `NEXT_PUBLIC_BASE_URL` | Public origin for web routes. | — |
| `AUTHENTIK_*` / `AUTH_OIDC_*` | OIDC issuer, audience, and client credentials. | required |
| `MINIO_ENDPOINT` / `MINIO_BUCKET` | Object storage endpoint + bucket for artifacts. | — |
| `REDIS_URL` | Celery broker/result backend. | `redis://cvfs-redis:6379/0` |
| `DATABASE_URL` | SQLAlchemy DSN for Postgres. | `postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches` |
| `ANTHROPIC_API_KEY` | Enables AI tailoring jobs. | unset |
`.env.example` documents every supported variable. `make envlink` symlinks root `.env` into all app directories.
## Repository layout
```
apps/
webapp/ Next.js 15 + React 19 + Tailwind 4 + Supabase auth (Bun, Turbopack)
webapp-minimal/ Streamlit quick prototype
backend/
fastapi/ FastAPI server (set BACKEND_MODE=fastapi)
flask/ Flask server (set BACKEND_MODE=flask)
worker/ Celery background worker backed by Redis
ml/
configs/ YAML config for data + training hyperparameters
models/ arch.py (architecture) + train.py (training loop)
data/ etl.py + processed artifacts
inference.py FastAPI inference server
notebooks/ Jupyter notebooks
alveslib/ Shared Python utilities (logger, scraper, agent)
src/ Simple scripts / CLI entry points
webapp/ # Next.js dashboard + public routes
webapp-minimal/ # Streamlit prototype (optional)
backend/fastapi/ # FastAPI service with routers, schemas, services
backend/flask/ # Alternative API (mostly unused)
worker/ # Celery worker + job modules
alveslib/ # Shared logging/agent utilities
dlib/ # CV parser + patch engine + storage helpers
docs/ # Architecture notes, deployment playbooks, diagrams
ml/ # Optional ML experiments (etl/train/infer)
src/ # Small scripts / CLI helpers
docker/ # Dockerfiles for backend/webapp/worker
Makefile # Developer entrypoints
```
## Make Targets
## Development routines
| Command | Purpose |
|---------|---------|
| `make init` | Bootstrap uv venv, install Python deps, run Bun installs, symlink `.env`. |
| `make dev` | Start the Next.js app (`bun x nx run webapp:dev`). |
| `make run.backend` | Launch FastAPI (switchable `BACKEND_MODE`). |
| `make run.worker` | Start Celery worker (requires Redis & MinIO). |
| `make lift.minio` | Local MinIO server + console for artifact testing. |
| `make lift.database` | Start Postgres (compose profile). |
| `make up` | Bring up redis + worker for background jobs. |
| `make nx.projects` | Inspect Nx workspace graph. |
| `make test` | Run pytest (unit/integration suites). |
| Target | Description |
|--------|-------------|
| `make init` | First-time setup |
| `make dev` | Start Next.js webapp |
| `make up` | Start Docker core services |
| `make run.backend` | Start API backend |
| `make run.worker` | Start Celery worker |
| `make nx.graph` | Open Nx project graph |
| `make nx.affected` | Run lint/test/build for affected projects |
| `make lift.minio` | Start MinIO object storage |
| `make lift.logging` | Start Loki + Grafana |
| `make lift.mlflow` | Start optional MLflow server |
| `make lift.database` | Start Postgres / MongoDB |
| `make doctor` | Verify toolchain |
Use `bun x nx affected -t lint,test,build` before PRs to run fine-grained checks.
Run `make help` for the full list.
## Deployment notes
- **Docker images:** `docker/backend-fastapi.Dockerfile`, `docker/webapp.Dockerfile`, and `docker/worker.Dockerfile` bake env args for Dokploy. The web build now receives `API_BASE_URL` so rewrites point at the deployed backend.
- **Dokploy:** see `docs/resume-branches/dokploy.md` for the API payload that provisions `cvfs-backend`, `cvfs-webapp`, Redis, Postgres, and MinIO.
- **Storage:** `make lift.minio` mirrors the production bucket layout. Set `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD` for parity. Public artifacts should only be published via the API to guarantee immutability + audit logging.
## Nx Workspace
## Limitations & next steps
- AI tailoring currently operates synchronously per submission; multi-edit batching and historical rollbacks are in progress.
- Auth is wired to generic OIDC (Authentik-ready) but does not yet expose roles beyond owner/admin.
- The worker queue is Redis-backed; scale-out deployments should move to a managed Redis + add observability (Prometheus, structured tracing).
This template now ships with Nx project definitions for:
- `webapp` (`apps/webapp`)
- `webapp-minimal` (`apps/webapp-minimal`)
- `backend-fastapi` (`apps/backend/fastapi`)
- `backend-flask` (`apps/backend/flask`)
- `worker` (`apps/worker`)
- `ml` (`ml`)
- `alveslib` (`alveslib`)
Common commands:
```bash
bun x nx show projects
bun x nx graph
bun x nx run webapp:dev
bun x nx affected -t lint,test,build
```
## AI Agent Capacity
Set `ANTHROPIC_API_KEY` in `.env`. Then use:
```python
from alveslib import ask, stream, Agent
# One-shot
print(ask("Summarize this data: ..."))
# Streaming
for chunk in stream("Write a Celery task that ..."):
print(chunk, end="", flush=True)
# Multi-turn
agent = Agent(system="You are a senior Python developer.")
agent.chat("Scaffold a FastAPI endpoint for user profiles")
agent.chat("Add input validation and error handling")
```
Claude Code slash commands (type `/` in a Claude Code session):
- `/plan` - implementation plan for an idea within this boilerplate
- `/build` - implement a feature end-to-end
- `/api` - scaffold a new backend endpoint
- `/page` - scaffold a new Next.js page
- `/review` - code review of recent changes
- `/ship` - stage and commit changes
## Logging
```python
from alveslib import get_logger
logger = get_logger("service")
```
Outputs structured JSON to console + `./logs/`. Optional Loki push when `LOKI_PORT` is set and `make lift.logging` is running. View in Grafana at `http://localhost:$GRAFANA_PORT` (add Loki data source: `http://loki:3100`).
## Python Packaging
Python dependencies are managed with `pyproject.toml` and `uv`.
```bash
make deps # uv sync
make lock # refresh uv.lock
uv run pytest -v
```
## ML Workflow
High-level ML hyperparameters live in YAML configs:
- `ml/configs/data/default.yaml`
- `ml/configs/train/default.yaml`
Run with Nx targets (cacheable with explicit inputs/outputs):
```bash
bun x nx run ml:etl
bun x nx run ml:train
```
`ml:train` depends on `ml:etl`, and both targets cache artifacts in `ml/data/processed`, `ml/models/weights`, and `ml/tensorboard`.
## Services (docker compose profiles)
| Profile | Services | Command |
|---------|----------|---------|
| _(default)_ | redis, ml-inference, worker | `make up` |
| `minio` | + MinIO object storage | `make lift.minio` |
| `tensorboard` | + TensorBoard | `make lift.tensorboard` |
| `mlflow` | + MLflow tracking server (optional) | `make lift.mlflow` |
| `logging` | + Loki + Grafana | `make lift.logging` |
| `database` | + Postgres + MongoDB | `make lift.database` |
## Webapp Auth
Auth is off by default (`NEXT_PUBLIC_REQUIRE_AUTH=false`). Set it to `true` and configure Supabase keys to enable session-based auth gating across all routes.
## Resume Branches MVP
This repo now powers “Resume Branches”, a CV control plane built on the dstack layout.
- **Backend** (`apps/backend/fastapi`): FastAPI API that ingests ATS-safe DOCX files, stores structured blocks, supports branching and submissions, and exposes publish endpoints. Configure MinIO via `MINIO_*` env vars. Build image via `docker/backend-fastapi.Dockerfile`.
- **Webapp** (`apps/webapp`): Next.js dashboard featuring the CV tree, upload workflow, and publish tooling. It targets the FastAPI host set in `NEXT_PUBLIC_API_BASE_URL` and builds with `docker/webapp.Dockerfile`.
- **Worker** (`apps/worker`): Celery worker handling heavy DOCX parsing and AI tailoring suggestions using Redis and MinIO.
- **Docs**: `docs/resume-branches/architecture.md` (system design) and `docs/resume-branches/dokploy.md` (Dokploy API payloads for deploying backend to `api.cv.alves.world` and webapp to `cv.alves.world`).
Local storage uses MinIO via `make lift.minio`; publish-ready artifacts live under the configured bucket.
## License
MIT — see `LICENSE` for details.

View File

@@ -2,10 +2,11 @@ from __future__ import annotations
from fastapi import APIRouter
from app.api.routes import documents, versions, submissions, public
from app.api.routes import documents, insights, versions, submissions, public
api_router = APIRouter()
api_router.include_router(documents.router)
api_router.include_router(versions.router)
api_router.include_router(submissions.router)
api_router.include_router(public.router)
api_router.include_router(insights.router)

View File

@@ -5,22 +5,30 @@ from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import DocumentListResponse, DocumentResponse
from app.services.documents import create_document, get_document, list_documents
from app.core.config import get_settings
from app.schemas import DocumentListResponse, DocumentResponse, VersionResponse
from app.services.documents import (
create_document,
delete_document,
get_document,
list_documents,
)
from app.services.storage import storage_client
from app.services.versions import upload_docx_to_version
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("/", response_model=DocumentListResponse)
@router.get("", response_model=DocumentListResponse)
async def list_user_documents(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
documents = await list_documents(session, owner_id=user.sub)
payload = [DocumentResponse.model_validate(doc) for doc in documents]
payload = [_build_document_response(doc) for doc in documents]
return DocumentListResponse(items=payload)
@@ -33,7 +41,7 @@ async def get_user_document(
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.model_validate(document)
return _build_document_response(document)
@router.get("/{document_id}/versions/{version_id}/download")
@@ -49,7 +57,8 @@ async def download_version_docx(
version = next((v for v in document.versions if v.id == version_id), None)
if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found")
data = storage_client.download_bytes(key=version.artifact_docx_key)
original = storage_client.download_bytes(key=version.artifact_docx_key)
data = generate_patched_docx(original, version.structured_blocks or [])
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
return Response(
content=data,
@@ -58,7 +67,51 @@ async def download_version_docx(
)
@router.post("/", response_model=DocumentResponse)
@router.get("/{document_id}/versions/{version_id}/preview")
async def preview_version_pdf(
document_id: str,
version_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
version = next((v for v in document.versions if v.id == version_id), None)
if not version or not version.artifact_docx_key:
raise HTTPException(status_code=404, detail="Version artifact not found")
original = storage_client.download_bytes(key=version.artifact_docx_key)
patched = generate_patched_docx(original, version.structured_blocks or [])
pdf = docx_bytes_to_pdf(patched)
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}"
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.post("/{document_id}/versions/{version_id}/upload", response_model=VersionResponse)
async def upload_docx_to_branch(
document_id: str,
version_id: str,
file: UploadFile = File(...),
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
version = next((v for v in document.versions if v.id == version_id), None)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
updated = await upload_docx_to_version(session, owner_id=user.sub, version_id=version_id, upload=file)
if not updated:
raise HTTPException(status_code=404, detail="Version not found")
return VersionResponse.model_validate(updated)
@router.post("", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: str | None = Form(default=None),
@@ -73,4 +126,32 @@ async def upload_document(
description=description,
upload=file,
)
return DocumentResponse.model_validate(document)
return _build_document_response(document)
@router.delete("/{document_id}", status_code=204)
async def delete_user_document(
document_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
if not deleted:
raise HTTPException(status_code=404, detail="Document not found")
def _build_document_response(document) -> DocumentResponse:
settings = get_settings()
base = (settings.public_base_url or "").rstrip("/")
response = DocumentResponse.model_validate(document)
versions = []
for version in response.versions:
assets = []
for asset in version.public_assets:
slug = asset.slug
url = asset.url
if slug and not url:
url = f"{base}/cv/{slug}" if base else f"/cv/{slug}"
assets.append(asset.model_copy(update={"url": url}))
versions.append(version.model_copy(update={"public_assets": assets}))
return response.model_copy(update={"versions": versions})

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas.insights import InsightsResponse
from app.services.insights import get_insights
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/insights", tags=["insights"])
@router.get("", response_model=InsightsResponse)
async def insights_endpoint(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
result = await get_insights(session, owner_id=user.sub)
return InsightsResponse(
total_submissions=result.total_submissions,
positive_count=result.positive_count,
positive_rate=result.positive_rate,
operation_impact=[
{"operation": o.operation, "total": o.total, "positive": o.positive, "rate": o.rate}
for o in result.operation_impact
],
top_positive_keywords=[
{"keyword": k.keyword, "positive_count": k.positive_count, "negative_count": k.negative_count, "lift": k.lift}
for k in result.top_positive_keywords
],
top_negative_keywords=[
{"keyword": k.keyword, "positive_count": k.positive_count, "negative_count": k.negative_count, "lift": k.lift}
for k in result.top_negative_keywords
],
section_impact=[
{"section": s.section, "positive_rate": s.positive_rate, "count": s.count}
for s in result.section_impact
],
has_data=result.has_data,
)

View File

@@ -1,20 +1,53 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"])
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None
view = PublicAssetView(
public_asset_id=asset.id,
viewed_at=datetime.now(timezone.utc),
user_agent=request.headers.get("user-agent", "")[:512] or None,
ip_hash=ip_hash,
)
session.add(view)
await session.commit()
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
@@ -33,21 +66,78 @@ async def publish(
return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
)
).scalar() or 0
last_viewed_at = (
await session.execute(
select(PublicAssetView.viewed_at)
.where(PublicAssetView.public_asset_id == asset.id)
.order_by(PublicAssetView.viewed_at.desc())
.limit(1)
)
).scalar()
return PublicAssetAnalyticsResponse(
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
@router.get("/{slug}/pdf")
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
version: CvVersion | None = None
if asset.version_id:
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
version = (await session.execute(stmt)).scalars().one_or_none()
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
blocks = version.structured_blocks or [] if version else []
patched = generate_patched_docx(docx_bytes, blocks)
pdf_bytes = docx_bytes_to_pdf(patched)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
base = settings.public_base_url.rstrip("/")
url = f"{base}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,

View File

@@ -8,16 +8,50 @@ from app.schemas import (
AiSuggestionRequest,
SubmissionCreateRequest,
SubmissionResponse,
SubmissionStatusUpdateRequest,
SuggestionResponse,
SuggestionUpdateRequest,
)
from app.services.submissions import create_submission, request_ai_suggestions
from app.services.submissions import (
create_submission,
get_submission,
list_submissions,
request_ai_suggestions,
update_submission_status,
update_suggestion,
)
from app.models import SubmissionStatus
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.post("/", response_model=SubmissionResponse)
@router.get("", response_model=list[SubmissionResponse])
async def list_submissions_endpoint(
version_id: str | None = None,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
items = await list_submissions(session, owner_id=user.sub, version_id=version_id)
return [SubmissionResponse.model_validate(s) for s in items]
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission_endpoint(
submission_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await get_submission(
session, owner_id=user.sub, submission_id=submission_id
)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db),
@@ -37,6 +71,24 @@ async def create_submission_endpoint(
return SubmissionResponse.model_validate(submission)
@router.patch("/{submission_id}/status", response_model=SubmissionResponse)
async def update_submission_status_endpoint(
submission_id: str,
payload: SubmissionStatusUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await update_submission_status(
session,
owner_id=user.sub,
submission_id=submission_id,
status=SubmissionStatus(payload.status),
)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("/{submission_id}/ai", response_model=list[SuggestionResponse])
async def request_submissions_ai(
submission_id: str,
@@ -54,3 +106,25 @@ async def request_submissions_ai(
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions]
@router.patch(
"/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse
)
async def update_suggestion_endpoint(
submission_id: str,
suggestion_id: str,
payload: SuggestionUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestion = await update_suggestion(
session,
owner_id=user.sub,
submission_id=submission_id,
suggestion_id=suggestion_id,
accepted=payload.accepted,
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
return SuggestionResponse.model_validate(suggestion)

View File

@@ -4,9 +4,14 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch
from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
from app.services.versions import (
append_patches_to_version,
create_branch,
delete_version,
)
from dlib.auth import AuthenticatedUser
from dlib.cv.ats_guard import PatchValidationError
router = APIRouter(prefix="/versions", tags=["versions"])
@@ -18,14 +23,55 @@ async def create_version_branch(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
try:
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
except PatchValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not version:
raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version)
@router.delete("/{version_id}", status_code=204)
async def delete_version_branch(
version_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
result = await delete_version(session, owner_id=user.sub, version_id=version_id)
if result is False:
raise HTTPException(status_code=404, detail="Version not found")
if result == "root":
raise HTTPException(status_code=400, detail="Cannot delete root version")
if result == "has_children":
raise HTTPException(status_code=409, detail="Delete child branches first")
@router.post("/{version_id}/patches", response_model=VersionResponse)
async def append_patches(
version_id: str,
payload: PatchApplyRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
if not payload.patches:
raise HTTPException(status_code=400, detail="No patches provided")
try:
version = await append_patches_to_version(
session,
owner_id=user.sub,
version_id=version_id,
patches=payload.patches,
)
except PatchValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not version:
raise HTTPException(status_code=404, detail="Version not found")
return VersionResponse.model_validate(version)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import json
from functools import lru_cache
from typing import List
from typing import Annotated, List
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, NoDecode
class Settings(BaseSettings):
@@ -15,7 +16,7 @@ class Settings(BaseSettings):
default="postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches",
alias="DATABASE_URL",
)
cors_origins: List[str] = Field(
cors_origins: Annotated[List[str], NoDecode] = Field(
default_factory=lambda: ["http://localhost:3000"], alias="CORS_ORIGINS"
)
@@ -54,9 +55,25 @@ class Settings(BaseSettings):
@classmethod
def _split_origins(cls, value):
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped.startswith("["):
parsed = json.loads(stripped)
if isinstance(parsed, list):
return [
str(origin).strip() for origin in parsed if str(origin).strip()
]
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@field_validator("storage_endpoint_url", mode="before")
@classmethod
def _empty_endpoint_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

@@ -4,6 +4,7 @@ from .cv import (
CvPatch,
CvVersion,
PublicAsset,
PublicAssetView,
Specialization,
Submission,
SubmissionStatus,
@@ -17,5 +18,6 @@ __all__ = [
"Submission",
"SubmissionStatus",
"PublicAsset",
"PublicAssetView",
"AiSuggestion",
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
@@ -21,7 +22,11 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
)
versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion", back_populates="document"
"CvVersion",
back_populates="document",
foreign_keys="[CvVersion.document_id]",
cascade="all, delete-orphan",
passive_deletes=True,
)
@@ -41,13 +46,27 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
document: Mapped[CvDocument] = relationship(
"CvDocument", back_populates="versions", foreign_keys="[CvVersion.document_id]"
)
parent: Mapped["CvVersion | None"] = relationship(
"CvVersion", remote_side="[CvVersion.id]"
)
patches: Mapped[list["CvPatch"]] = relationship("CvPatch", back_populates="version")
patches: Mapped[list["CvPatch"]] = relationship(
"CvPatch",
back_populates="version",
cascade="all, delete-orphan",
)
submissions: Mapped[list["Submission"]] = relationship(
"Submission", back_populates="version"
"Submission",
back_populates="version",
cascade="all, delete-orphan",
)
public_assets: Mapped[list["PublicAsset"]] = relationship(
"PublicAsset",
back_populates="version",
cascade="all, delete-orphan",
passive_deletes=True,
)
@@ -105,7 +124,9 @@ class Submission(Base, IdentifierMixin, TimestampMixin):
version: Mapped[CvVersion] = relationship("CvVersion", back_populates="submissions")
suggestions: Mapped[list["AiSuggestion"]] = relationship(
"AiSuggestion", back_populates="submission"
"AiSuggestion",
back_populates="submission",
cascade="all, delete-orphan",
)
public_asset: Mapped["PublicAsset | None"] = relationship(
"PublicAsset", back_populates="submission"
@@ -119,7 +140,7 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
)
version_id: Mapped[str | None] = mapped_column(
ForeignKey("cv_versions.id"), nullable=True
ForeignKey("cv_versions.id", ondelete="CASCADE"), nullable=True
)
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
artifact_key: Mapped[str] = mapped_column(String(512))
@@ -131,7 +152,32 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
submission: Mapped[Submission | None] = relationship(
"Submission", back_populates="public_asset"
)
version: Mapped[CvVersion | None] = relationship("CvVersion")
version: Mapped[CvVersion | None] = relationship(
"CvVersion", back_populates="public_assets"
)
views: Mapped[list["PublicAssetView"]] = relationship(
"PublicAssetView",
back_populates="public_asset",
cascade="all, delete-orphan",
passive_deletes=True,
)
class PublicAssetView(Base, IdentifierMixin):
__tablename__ = "public_asset_views"
public_asset_id: Mapped[str] = mapped_column(
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
public_asset: Mapped[PublicAsset] = relationship(
"PublicAsset", back_populates="views"
)
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

@@ -4,12 +4,16 @@ from .cv import (
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PatchApplyRequest,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
SubmissionCreateRequest,
SubmissionResponse,
SubmissionStatusUpdateRequest,
SuggestionResponse,
SuggestionUpdateRequest,
VersionResponse,
)
@@ -19,11 +23,15 @@ __all__ = [
"DocumentCreateResult",
"VersionResponse",
"BranchCreateRequest",
"PatchApplyRequest",
"SubmissionCreateRequest",
"SubmissionResponse",
"SubmissionStatusUpdateRequest",
"AiSuggestionRequest",
"SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
]

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -34,6 +34,7 @@ class VersionResponse(BaseModel):
created_at: datetime
updated_at: datetime
patches: list["PatchResponse"] = Field(default_factory=list)
public_assets: list["PublicAssetResponse"] = Field(default_factory=list)
class PatchResponse(BaseModel):
@@ -63,6 +64,10 @@ class BranchCreateRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list)
class PatchApplyRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list)
class SubmissionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -85,6 +90,16 @@ class SubmissionCreateRequest(BaseModel):
job_description: str | None = None
class SubmissionStatusUpdateRequest(BaseModel):
status: Literal[
"draft",
"tailoring",
"pending_review",
"published",
"archived",
]
class SuggestionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -123,3 +138,13 @@ class PublicAssetResponse(BaseModel):
class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse
class PublicAssetAnalyticsResponse(BaseModel):
slug: str
view_count: int
last_viewed_at: datetime | None = None
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from pydantic import BaseModel
class OperationImpactSchema(BaseModel):
operation: str
total: int
positive: int
rate: float
class KeywordSignalSchema(BaseModel):
keyword: str
positive_count: int
negative_count: int
lift: float
class SectionImpactSchema(BaseModel):
section: str
positive_rate: float
count: int
class InsightsResponse(BaseModel):
total_submissions: int
positive_count: int
positive_rate: float
operation_impact: list[OperationImpactSchema]
top_positive_keywords: list[KeywordSignalSchema]
top_negative_keywords: list[KeywordSignalSchema]
section_impact: list[SectionImpactSchema]
has_data: bool

View File

@@ -1,14 +1,14 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.cv import parse_docx_bytes
from app.models import CvDocument, CvVersion
from app.services.storage import persist_upload
from app.models import CvDocument, CvVersion, PublicAsset
from app.services.storage import persist_upload, storage_client
async def create_document(
@@ -23,28 +23,47 @@ async def create_document(
structured = parse_docx_bytes(file_bytes, version_label="root")
doc = CvDocument(owner_id=owner_id, title=title, description=description)
session.add(doc)
await session.flush() # persist doc so version FK is satisfied
version = CvVersion(
document=doc,
document_id=doc.id,
branch_name="root",
version_label="root",
artifact_docx_key=artifact_key,
structured_blocks=[block.model_dump() for block in structured.blocks],
metadata_json={"ingested": True},
)
doc.versions.append(version)
doc.root_version_id = version.id
session.add(version)
await session.flush() # persist version so root_version_id FK is satisfied
session.add(doc)
doc.root_version_id = version.id
await session.commit()
await session.refresh(doc, attribute_names=["versions"])
return doc
stmt = (
select(CvDocument)
.where(CvDocument.id == doc.id)
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
)
result = await session.execute(stmt)
return result.scalars().unique().one()
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
stmt = (
select(CvDocument)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
.order_by(CvDocument.created_at.desc())
)
result = await session.execute(stmt)
@@ -57,7 +76,31 @@ async def get_document(
stmt = (
select(CvDocument)
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
.options(
selectinload(CvDocument.versions).options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
)
result = await session.execute(stmt)
return result.scalars().unique().one_or_none()
async def delete_document(
session: AsyncSession, owner_id: str, document_id: str
) -> bool:
doc = await get_document(session, owner_id, document_id)
if not doc:
return False
artifact_keys = {v.artifact_docx_key for v in doc.versions if v.artifact_docx_key}
version_ids = [v.id for v in doc.versions]
if version_ids:
await session.execute(
delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids))
)
await session.delete(doc)
await session.commit()
for key in artifact_keys:
storage_client.delete_object(key=key)
return True

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.ai.insights import InsightsResult, SubmissionRecord, SuggestionRecord, analyze
from app.models import AiSuggestion, CvDocument, CvVersion, Submission
async def get_insights(session: AsyncSession, *, owner_id: str) -> InsightsResult:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
rows = list((await session.execute(stmt)).scalars().all())
records = [
SubmissionRecord(
status=s.status.value,
suggestions=[
SuggestionRecord(
operation=sug.operation,
target_path=sug.target_path,
proposed_text=sug.proposed_text,
rationale=sug.rationale,
accepted=sug.accepted,
)
for sug in s.suggestions
],
)
for s in rows
]
return analyze(records)

View File

@@ -24,8 +24,8 @@ async def publish_version(
if submission_id:
stmt = (
select(Submission)
.join(CvVersion)
.join(CvDocument)
.join(Submission.version)
.join(CvVersion.document)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -34,7 +34,7 @@ async def publish_version(
elif version_id:
stmt = (
select(CvVersion)
.join(CvDocument)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)

View File

@@ -33,8 +33,7 @@ async def create_submission(
)
session.add(submission)
await session.commit()
await session.refresh(submission)
return submission
return await _get_submission_for_owner(session, owner_id, submission.id)
async def request_ai_suggestions(
@@ -84,12 +83,76 @@ async def request_ai_suggestions(
return created
async def list_submissions(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None = None,
) -> list[Submission]:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
if version_id:
stmt = stmt.where(Submission.version_id == version_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_submission(
session: AsyncSession, *, owner_id: str, submission_id: str
) -> Submission | None:
return await _get_submission_for_owner(session, owner_id, submission_id)
async def update_suggestion(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
suggestion_id: str,
accepted: bool,
) -> AiSuggestion | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
stmt = select(AiSuggestion).where(
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
)
result = await session.execute(stmt)
suggestion = result.scalars().one_or_none()
if not suggestion:
return None
suggestion.accepted = accepted
await session.commit()
await session.refresh(suggestion)
return suggestion
async def update_submission_status(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
status: SubmissionStatus,
) -> Submission | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
submission.status = status
await session.commit()
return await _get_submission_for_owner(session, owner_id, submission_id)
async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvDocument)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
@@ -103,10 +166,13 @@ async def _get_submission_for_owner(
) -> Submission | None:
stmt = (
select(Submission)
.join(CvVersion)
.join(CvDocument)
.join(Submission.version)
.join(CvVersion.document)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
.options(selectinload(Submission.version))
.options(
selectinload(Submission.version),
selectinload(Submission.suggestions),
)
)
result = await session.execute(stmt)
return result.scalars().one_or_none()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from sqlalchemy import select
from fastapi import UploadFile
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,11 +9,34 @@ from dlib.cv import (
StructuredBlock,
StructuredDocument,
PatchPayload,
PatchOperation,
apply_patchset,
validate_patchset,
parse_docx_bytes,
)
from app.models import CvDocument, CvPatch, CvVersion
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
from app.services.storage import persist_upload
def _diff_blocks(old: list[dict], new: list[dict]) -> list[PatchPayload]:
old_map = {b["path"]: b for b in old}
new_map = {b["path"]: b for b in new}
patches: list[PatchPayload] = []
for path, nb in new_map.items():
ob = old_map.get(path)
if ob and ob["text"] != nb["text"]:
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REPLACE_TEXT,
old_value=ob["text"], new_value=nb["text"],
))
for path, ob in old_map.items():
if path not in new_map and ob.get("block_type") != "heading":
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REMOVE_BLOCK,
old_value=ob["text"],
))
return patches
async def create_branch(
@@ -26,7 +50,7 @@ async def create_branch(
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvDocument)
.join(CvVersion.document)
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches))
)
@@ -74,5 +98,159 @@ async def create_branch(
)
await session.commit()
await session.refresh(new_version)
return new_version
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == new_version.id)
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
return result.scalars().one()
async def append_patches_to_version(
session: AsyncSession,
*,
owner_id: str,
version_id: str,
patches: list[dict],
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt)
version = result.scalars().one_or_none()
if not version:
return None
patch_models = [PatchPayload.model_validate(item) for item in patches]
if not patch_models:
return version
base_doc = StructuredDocument(
version_label=version.version_label,
blocks=[
StructuredBlock.model_validate(block)
for block in version.structured_blocks or []
],
)
validate_patchset(base_doc, patch_models)
updated_doc = apply_patchset(base_doc, patch_models)
version.structured_blocks = [block.model_dump() for block in updated_doc.blocks]
metadata = version.metadata_json or {}
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(patch_models)
version.metadata_json = metadata
for patch in patch_models:
session.add(
CvPatch(
version_id=version.id,
target_path=patch.target_path,
operation=patch.operation.value,
old_value=patch.old_value,
new_value=patch.new_value,
metadata_json=patch.metadata,
)
)
await session.commit()
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
return result.scalars().one()
async def upload_docx_to_version(
session: AsyncSession,
*,
owner_id: str,
version_id: str,
upload: UploadFile,
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
version = (await session.execute(stmt)).scalars().one_or_none()
if not version:
return None
artifact_key, file_bytes = await persist_upload(upload, owner_id)
new_blocks_parsed = parse_docx_bytes(file_bytes)
new_blocks = [b.model_dump() for b in new_blocks_parsed.blocks]
diff_patches = _diff_blocks(version.structured_blocks or [], new_blocks)
version.artifact_docx_key = artifact_key
version.structured_blocks = new_blocks
metadata = version.metadata_json or {}
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(diff_patches)
version.metadata_json = metadata
for patch in diff_patches:
session.add(CvPatch(
version_id=version.id,
target_path=patch.target_path,
operation=patch.operation.value,
old_value=patch.old_value,
new_value=patch.new_value,
metadata_json=patch.metadata,
))
await session.commit()
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
return (await session.execute(stmt_refresh)).scalars().one()
async def delete_version(
session: AsyncSession, owner_id: str, version_id: str
) -> bool | str:
"""Delete a non-root branch. Returns False if not found, 'root' if root, True on success."""
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
version = result.scalars().one_or_none()
if not version:
return False
if not version.parent_version_id:
return "root"
# Refuse if child branches exist
child_stmt = (
select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
)
child_result = await session.execute(child_stmt)
if child_result.scalar_one_or_none():
return "has_children"
await session.execute(
delete(PublicAsset).where(PublicAsset.version_id == version_id)
)
await session.delete(version)
await session.commit()
return True

View File

@@ -7,7 +7,7 @@
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.57.4",
"next": "15.5.2",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
},
@@ -18,7 +18,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.14",
"tailwindcss": "^4",
"typescript": "^5",
},
@@ -117,25 +117,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.2", "", {}, "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg=="],
"@next/env": ["@next/env@15.5.14", "", {}, "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.2", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -395,7 +395,7 @@
"eslint": ["eslint@9.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.35.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg=="],
"eslint-config-next": ["eslint-config-next@15.5.2", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.2", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" } }, "sha512-3hPZghsLupMxxZ2ggjIIrat/bPniM2yRpsVPVM40rp8ZMzKWOJp2CGWn7+EzoV2ddkUr5fxNfHpF+wU1hGt/3g=="],
"eslint-config-next": ["eslint-config-next@15.5.14", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.14", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-lmJ5F8ZgOYogq0qtH4L5SpxuASY2SPdOzqUprN2/56+P3GPsIpXaUWIJC66kYIH+yZdsM4nkHE5MIBP6s1NiBw=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
@@ -635,7 +635,7 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.2", "", { "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.2", "@next/swc-darwin-x64": "15.5.2", "@next/swc-linux-arm64-gnu": "15.5.2", "@next/swc-linux-arm64-musl": "15.5.2", "@next/swc-linux-x64-gnu": "15.5.2", "@next/swc-linux-x64-musl": "15.5.2", "@next/swc-win32-arm64-msvc": "15.5.2", "@next/swc-win32-x64-msvc": "15.5.2", "sharp": "^0.34.3" }, "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" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q=="],
"next": ["next@15.5.14", "", { "dependencies": { "@next/env": "15.5.14", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.14", "@next/swc-darwin-x64": "15.5.14", "@next/swc-linux-arm64-gnu": "15.5.14", "@next/swc-linux-arm64-musl": "15.5.14", "@next/swc-linux-x64-gnu": "15.5.14", "@next/swc-linux-x64-musl": "15.5.14", "@next/swc-win32-arm64-msvc": "15.5.14", "@next/swc-win32-x64-msvc": "15.5.14", "sharp": "^0.34.3" }, "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" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -843,8 +843,6 @@
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(process.cwd(), "../.."),
async rewrites() {
const backend = process.env.API_BASE_URL ?? "https://api.cv.alves.world";
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
};

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.57.4",
"next": "15.5.2",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@@ -24,7 +24,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.14",
"tailwindcss": "^4",
"typescript": "^5"
}

Binary file not shown.

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuerRaw = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
const redirectUri = `${publicBase}/api/auth/callback`;
const authentikHost = authentikBase(issuerRaw);
if (!authentikHost || !clientId || !clientSecret) {
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
}
const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret,
}),
}).catch(() => null);
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${publicBase}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
// non-httpOnly copy for client-side API bearer usage
res.cookies.set('oidc_token_pub', tokens.access_token, {
httpOnly: false, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
// in-memory rate limiter: max 10 attempts per IP per 15 min
const attempts = new Map<string, { n: number; resetAt: number }>();
function checkRate(ip: string): boolean {
const now = Date.now();
const e = attempts.get(ip);
if (!e || now > e.resetAt) { attempts.set(ip, { n: 1, resetAt: now + 15 * 60_000 }); return true; }
if (e.n >= 10) return false;
e.n++;
return true;
}
function safeEq(a: string, b: string): boolean {
try {
const ab = Buffer.from(a), bb = Buffer.from(b);
if (ab.length !== bb.length) {
// still run comparison on equal-length buffers to avoid timing leak
timingSafeEqual(ab, ab);
return false;
}
return timingSafeEqual(ab, bb);
} catch { return false; }
}
function sign(value: string) {
return createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown';
if (!checkRate(ip)) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const { username, password } = body as Record<string, unknown>;
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
}
const validUser = safeEq(username, LOGIN_USER);
const validPass = safeEq(password, LOGIN_PASS);
if (!validUser || !validPass) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });
res.cookies.set('session', token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete('session');
res.cookies.delete('oidc_token');
res.cookies.delete('oidc_token_pub');
return res;
}

View File

@@ -0,0 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const token = req.cookies.get('oidc_token')?.value ?? null;
return NextResponse.json({ token });
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const backend = process.env.API_BASE_URL ?? 'http://localhost:9812';
try {
const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
cache: 'no-store',
});
if (res.status === 404) return new NextResponse('CV not found', { status: 404 });
if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status });
const pdf = await res.arrayBuffer();
return new NextResponse(pdf, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${slug}.pdf"`,
'Cache-Control': 'public, max-age=300',
},
});
} catch (error) {
console.error('Error fetching public CV:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -0,0 +1,164 @@
import type { Document, Submission, InsightsResult } from '@/libs/api';
const D = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
const ROOT_VERSION_ID = 'demo-v1';
const ML_VERSION_ID = 'demo-v2';
const BACKEND_VERSION_ID = 'demo-v3';
export const DEMO_DOC_ID = 'demo-doc-1';
export const DEMO_DOCUMENTS: Document[] = [
{
id: DEMO_DOC_ID,
title: 'Alex Rivera — Software Engineer',
description: 'Main CV, ATS-safe baseline',
owner_id: 'demo-user',
root_version_id: ROOT_VERSION_ID,
created_at: D(45),
updated_at: D(3),
versions: [
{
id: ROOT_VERSION_ID,
branch_name: 'root',
version_label: 'v1.0 baseline',
parent_version_id: null,
structured_blocks: [
{ path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] },
{ path: 'summary[1]', block_type: 'summary', text: 'Software engineer with 5 years of experience building distributed systems and ML pipelines at scale.', keywords: ['distributed', 'systems', 'machine', 'learning'] },
{ path: 'heading[2]', block_type: 'heading', text: 'Experience', keywords: [] },
{ path: 'bullet[1]', block_type: 'bullet', text: 'Led migration of monolithic data pipeline to distributed microservices, reducing p99 latency by 40%.', keywords: ['distributed', 'microservices', 'latency', 'pipeline'] },
{ path: 'bullet[2]', block_type: 'bullet', text: 'Designed feature flag system used by 50+ engineers across 3 teams.', keywords: ['system', 'design', 'engineers'] },
{ path: 'heading[3]', block_type: 'heading', text: 'Skills', keywords: [] },
{ path: 'skills[1]', block_type: 'skills', text: 'Python, Go, TypeScript, SQL, Kubernetes, AWS, PyTorch', keywords: ['python', 'go', 'typescript', 'pytorch', 'kubernetes'] },
],
artifact_docx_key: 'demo/demo-cv.docx',
patches: [],
public_assets: [],
created_at: D(45),
updated_at: D(45),
},
{
id: ML_VERSION_ID,
branch_name: 'ml-engineer',
version_label: 'ML-focused variant',
parent_version_id: ROOT_VERSION_ID,
structured_blocks: [
{ path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] },
{ path: 'summary[1]', block_type: 'summary', text: 'ML engineer specialising in large-scale PyTorch training pipelines, distributed inference, and production-grade MLOps.', keywords: ['pytorch', 'distributed', 'mlops', 'inference'] },
{ path: 'heading[2]', block_type: 'heading', text: 'Experience', keywords: [] },
{ path: 'bullet[1]', block_type: 'bullet', text: 'Contributed PyTorch anomaly detection model achieving 92% precision on production traffic at 2M events/day.', keywords: ['pytorch', 'machine learning', 'production', 'precision'] },
{ path: 'bullet[2]', block_type: 'bullet', text: 'Built streaming data ingestion system (Kafka + Flink) powering real-time ML feature store.', keywords: ['kafka', 'flink', 'streaming', 'feature store'] },
{ path: 'heading[3]', block_type: 'heading', text: 'Skills', keywords: [] },
{ path: 'skills[1]', block_type: 'skills', text: 'PyTorch, Python, Go, Kubernetes, Spark, dbt, AWS SageMaker', keywords: ['pytorch', 'python', 'kubernetes', 'spark', 'sagemaker'] },
],
artifact_docx_key: 'demo/demo-cv.docx',
patches: [
{ id: 'dp1', target_path: 'summary[1]', operation: 'replace_text', old_value: 'Software engineer…', new_value: 'ML engineer specialising…', created_at: D(30) },
{ id: 'dp2', target_path: 'skills[1]', operation: 'boost_keyword', old_value: null, new_value: 'PyTorch', created_at: D(30) },
],
public_assets: [{
id: 'demo-asset-1', slug: 'alex-ml', artifact_key: 'public/alex-ml.docx',
is_public: true, url: '/demo-cv.docx', version_id: ML_VERSION_ID, submission_id: null, created_at: D(20),
}],
created_at: D(30),
updated_at: D(3),
},
{
id: BACKEND_VERSION_ID,
branch_name: 'backend-engineer',
version_label: 'Backend-focused variant',
parent_version_id: ROOT_VERSION_ID,
structured_blocks: [
{ path: 'heading[1]', block_type: 'heading', text: 'Alex Rivera', keywords: [] },
{ path: 'summary[1]', block_type: 'summary', text: 'Backend engineer focused on high-throughput API design, distributed systems, and reliability engineering.', keywords: ['backend', 'api', 'distributed', 'reliability'] },
{ path: 'bullet[1]', block_type: 'bullet', text: 'Led migration to microservices, reducing p99 latency by 40% under 10k RPS sustained load.', keywords: ['microservices', 'latency', 'rps', 'distributed'] },
{ path: 'skills[1]', block_type: 'skills', text: 'Go, Python, PostgreSQL, Redis, gRPC, Kubernetes, AWS', keywords: ['go', 'postgresql', 'redis', 'grpc', 'kubernetes'] },
],
artifact_docx_key: 'demo/demo-cv.docx',
patches: [
{ id: 'dp3', target_path: 'summary[1]', operation: 'replace_text', old_value: 'Software engineer…', new_value: 'Backend engineer…', created_at: D(25) },
],
public_assets: [],
created_at: D(25),
updated_at: D(10),
},
],
},
];
export const DEMO_SUBMISSIONS: Submission[] = [
{
id: 'ds1', version_id: ML_VERSION_ID, company_name: 'Anthropic', role_title: 'ML Research Engineer',
job_url: null, job_description: null, status: 'pending_review', created_at: D(18),
suggestions: [
{ id: 's1', target_path: 'summary[1]', operation: 'boost_keyword', proposed_text: 'constitutional ai', rationale: 'Highlight alignment research experience', accepted: true, metadata_json: { confidence: 0.82 } },
{ id: 's2', target_path: 'bullet[1]', operation: 'replace_text', proposed_text: 'Built distributed PyTorch training pipeline handling constitutional AI fine-tuning at scale.', rationale: 'Align with Anthropic stack', accepted: true, metadata_json: { confidence: 0.74 } },
],
},
{
id: 'ds2', version_id: ML_VERSION_ID, company_name: 'Google DeepMind', role_title: 'Senior ML Engineer',
job_url: null, job_description: null, status: 'pending_review', created_at: D(14),
suggestions: [
{ id: 's3', target_path: 'skills[1]', operation: 'boost_keyword', proposed_text: 'JAX', rationale: 'DeepMind uses JAX heavily', accepted: true, metadata_json: { confidence: 0.71 } },
{ id: 's4', target_path: 'bullet[2]', operation: 'replace_text', proposed_text: 'Built large-scale streaming pipeline underpinning real-time feature store for JAX model serving.', rationale: 'Add JAX context', accepted: true, metadata_json: { confidence: 0.68 } },
],
},
{
id: 'ds3', version_id: ML_VERSION_ID, company_name: 'OpenAI', role_title: 'Research Engineer',
job_url: null, job_description: null, status: 'published', created_at: D(10),
suggestions: [
{ id: 's5', target_path: 'summary[1]', operation: 'replace_text', proposed_text: 'ML engineer with track record in large-scale training infrastructure and RLHF pipelines.', rationale: 'OpenAI focus on RLHF', accepted: true, metadata_json: { confidence: 0.77 } },
],
},
{
id: 'ds4', version_id: ML_VERSION_ID, company_name: 'Meta AI', role_title: 'ML Infrastructure Engineer',
job_url: null, job_description: null, status: 'archived', created_at: D(22),
suggestions: [
{ id: 's6', target_path: 'bullet[1]', operation: 'boost_keyword', proposed_text: 'PyTorch', rationale: 'Meta maintains PyTorch', accepted: true, metadata_json: { confidence: 0.55 } },
{ id: 's7', target_path: 'summary[1]', operation: 'suppress_block', proposed_text: null, rationale: 'Summary too generic', accepted: false, metadata_json: { confidence: 0.3 } },
],
},
{
id: 'ds5', version_id: BACKEND_VERSION_ID, company_name: 'Stripe', role_title: 'Senior Backend Engineer',
job_url: null, job_description: null, status: 'pending_review', created_at: D(8),
suggestions: [
{ id: 's8', target_path: 'bullet[1]', operation: 'replace_text', proposed_text: 'Led migration to microservices achieving 99.99% uptime across Stripe-scale payment processing.', rationale: 'Emphasise reliability', accepted: true, metadata_json: { confidence: 0.79 } },
],
},
{
id: 'ds6', version_id: BACKEND_VERSION_ID, company_name: 'Cloudflare', role_title: 'Staff Engineer',
job_url: null, job_description: null, status: 'archived', created_at: D(20),
suggestions: [
{ id: 's9', target_path: 'skills[1]', operation: 'boost_keyword', proposed_text: 'Rust', rationale: 'Cloudflare uses Rust', accepted: true, metadata_json: { confidence: 0.4 } },
],
},
];
export const DEMO_INSIGHTS: InsightsResult = {
total_submissions: 6,
positive_count: 4,
positive_rate: 0.667,
has_data: true,
operation_impact: [
{ operation: 'replace_text', total: 5, positive: 4, rate: 0.8 },
{ operation: 'boost_keyword', total: 5, positive: 3, rate: 0.6 },
{ operation: 'suppress_block', total: 1, positive: 0, rate: 0.0 },
],
top_positive_keywords: [
{ keyword: 'pytorch', positive_count: 4, negative_count: 1, lift: 4.0 },
{ keyword: 'distributed', positive_count: 3, negative_count: 0, lift: 3.0 },
{ keyword: 'pipeline', positive_count: 3, negative_count: 1, lift: 3.0 },
{ keyword: 'scale', positive_count: 3, negative_count: 1, lift: 3.0 },
{ keyword: 'reliability', positive_count: 2, negative_count: 0, lift: 2.0 },
{ keyword: 'inference', positive_count: 2, negative_count: 0, lift: 2.0 },
],
top_negative_keywords: [
{ keyword: 'generic', positive_count: 0, negative_count: 2, lift: 0.0 },
{ keyword: 'suppress', positive_count: 0, negative_count: 1, lift: 0.0 },
],
section_impact: [
{ section: 'summary', positive_rate: 0.83, count: 6 },
{ section: 'bullet', positive_rate: 0.75, count: 4 },
{ section: 'skills', positive_rate: 0.5, count: 4 },
],
};

View File

@@ -3,12 +3,5 @@ export default function DashboardLayout({
}: {
children: React.ReactNode
}) {
return (
<div>
<nav>
<h1>Dashboard</h1>
</nav>
<main>{children}</main>
</div>
)
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -150,3 +150,111 @@ input:focus, textarea:focus, select:focus {
font-weight: 600;
margin-bottom: 16px;
}
/* ── dashboard layout ────────────────────────────────────────────────────────── */
.dashboard-root {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 44px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-toggle { display: none; }
.dashboard-body {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
overflow: auto;
display: flex;
flex-direction: column;
}
.sidebar-overlay { display: none; }
.main-panel {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
.action-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex-shrink: 0;
}
/* ── mobile breakpoint ───────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.sidebar-toggle { display: inline-flex; }
.sidebar {
position: fixed;
top: 44px;
left: 0;
bottom: 0;
z-index: 40;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 44px 0 0 0;
background: rgba(0, 0, 0, 0.25);
z-index: 39;
}
.modal {
max-width: 100% !important;
border-radius: 12px 12px 0 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-bottom: none;
}
.overlay {
align-items: flex-end;
}
.action-buttons {
gap: 4px;
}
.action-buttons .btn {
padding: 4px 8px;
font-size: 11px;
}
}

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Resume Branches",
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
title: "cvfs",
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,46 +1,3 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}
// Auth is handled via /api/auth/* route handlers.
// This file retained for compatibility; Supabase sign-in is no longer used.
export {};

View File

@@ -1,14 +1,155 @@
import { login, signup } from './actions'
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
const OIDC_ERROR_LABELS: Record<string, string> = {
no_code: 'No authorization code received from provider.',
oidc_not_configured: 'OIDC provider not configured.',
token_exchange: 'Failed to exchange token with provider.',
};
function authentikBase(url?: string | null) {
if (!url) return null;
try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; }
}
function authentikUrl() {
const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!baseHost || !clientId) return null;
return `${baseHost}/application/o/authorize/?${new URLSearchParams({
response_type: 'code', client_id: clientId,
redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile',
})}`;
}
function LoginForm() {
const router = useRouter();
const params = useSearchParams();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
useEffect(() => {
const e = params.get('error');
if (e) setError(OIDC_ERROR_LABELS[e] ?? `Auth error: ${e}`);
}, [params]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password || loading) return;
setLoading(true); setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
router.push('/dashboard');
} else {
const j = await res.json().catch(() => ({}));
setError(j.error ?? `Login failed (${res.status})`);
setLoading(false);
}
} catch {
setError('Network error — check your connection and try again.');
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center',
justifyContent: 'center', background: 'var(--bg)',
}}>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>cvfs</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Sign in to your account</div>
</div>
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px',
}}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Username
</label>
<input
type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin" disabled={loading}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Password
</label>
<input
type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••" disabled={loading}
/>
</div>
{error && (
<div style={{ fontSize: 12, color: '#dc2626', padding: '6px 10px', background: '#fef2f2', borderRadius: 4 }}>
{error}
</div>
)}
<button
type="submit" className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}
disabled={loading || !username || !password}
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{oidcUrl && (
<>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
margin: '16px 0', color: 'var(--text-faint)', fontSize: 12,
}}>
<hr className="divider" style={{ flex: 1 }} />
<span>or</span>
<hr className="divider" style={{ flex: 1 }} />
</div>
<a href={oidcUrl} style={{ textDecoration: 'none', display: 'block' }}>
<button className="btn btn-ghost" style={{ width: '100%', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Sign in with Authentik
</button>
</a>
</>
)}
</div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
cvfs CV File System
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
)
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}

View File

@@ -10,10 +10,10 @@ export default function Home() {
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
Resume Branches
cvfs
</p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
Git for CVs
CV File System
</h1>
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without

View File

@@ -4,9 +4,9 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8">
<div className="col-span-1">
<h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3>
<p className="text-sm mb-4">
Git for CVs. Manage your resume like code with version control,
CV File System. Manage your resume like code with version control,
branching, and smart AI-assisted tailoring.
</p>
</div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
<p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
<p className="text-sm">© 2024 cvfs. All rights reserved.</p>
<div className="flex items-center space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
return (
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
Resume Branches
cvfs
</Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

@@ -1,71 +1,226 @@
'use client';
import { useState } from 'react';
import { MouseEvent, useState } from 'react';
import { Version } from '@/libs/api';
type TreeNode = { version: Version; children: TreeNode[] };
type TreeNode = { version: Version; children: TreeNode[]; ancestorPatchCount: number };
function buildTree(versions: Version[]): TreeNode | null {
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }]));
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[], ancestorPatchCount: 0 }]));
let root: TreeNode | null = null;
for (const node of map.values()) {
const pid = node.version.parent_version_id;
if (!pid) { root = node; } else { map.get(pid)?.children.push(node); }
}
// annotate each node with total ancestor patch count
function annotate(node: TreeNode, inherited: number) {
node.ancestorPatchCount = inherited;
for (const child of node.children) annotate(child, inherited + node.version.patches.length);
}
if (root) annotate(root, 0);
return root;
}
function Node({ node, depth, selectedId, onSelect }: {
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
export function versionToMarkdown(version: Version, parentName?: string): string {
const header = `## ${version.version_label || version.branch_name}${parentName ? ` (from ${parentName})` : ''}`;
const blocks = (version.structured_blocks ?? []).map(b =>
`[${b.path}] ${b.block_type}: ${b.text}`
).join('\n');
return `${header}\n\n${blocks || '(no blocks)'}`;
}
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, onPropagate, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void;
onDelete?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
colorIndex?: number;
}) {
const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
const hasChildren = node.children.length > 0;
const canPropagate = hasChildren && v.patches.length > 0;
const handleCopy = (e: MouseEvent) => {
e.stopPropagation();
onCopyMarkdown?.(v.id);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div>
<div style={{ position: 'relative' }}>
{depth > 0 && (
<div style={{
position: 'absolute', left: -1, top: 15,
width: 14, height: 1, background: 'var(--border-strong)', zIndex: 1,
}} />
)}
<div
onClick={() => onSelect(v.id)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 12 + depth * 16, paddingRight: 8,
height: 30, cursor: 'pointer',
background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
display: 'flex', alignItems: 'center', gap: 6,
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4,
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent',
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
<button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
style={{
width: 12, height: 12, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
opacity: hasChildren ? 1 : 0, pointerEvents: hasChildren ? 'auto' : 'none',
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
</button>
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
<span style={{
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
borderRadius: '50%', flexShrink: 0,
background: isRoot || isSelected ? dotColor : 'transparent',
border: `2px solid ${dotColor}`,
transition: 'background 0.1s',
}} />
<span style={{
flex: 1, fontSize: 13,
fontWeight: isRoot ? 600 : 400,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isSelected ? 'var(--text)' : isRoot ? 'var(--text)' : 'var(--text-muted)',
}}>
{v.version_label || v.branch_name}
</span>
{/* own patch count */}
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
background: 'var(--hover)', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}>
{v.patches.length}
</span>
)}
{/* ancestor patch indicator on non-root branches */}
{!isRoot && node.ancestorPatchCount > 0 && (
<span
title={`${node.ancestorPatchCount} patch${node.ancestorPatchCount !== 1 ? 'es' : ''} inherited from ancestors`}
style={{
fontSize: 10, color: '#2563eb',
background: '#eff6ff', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}
>
{node.ancestorPatchCount}
</span>
)}
{hovered && onCopyMarkdown && (
<button
onClick={handleCopy}
title="Copy Markdown"
aria-label="Copy Markdown"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: copied ? '#059669' : 'var(--text-faint)',
flexShrink: 0, borderRadius: 3, fontSize: 11, lineHeight: 1,
}}
>
{copied ? '✓' : '⎘'}
</button>
)}
{/* propagate button: version has own patches and has children */}
{hovered && canPropagate && onPropagate && (
<button
onClick={e => { e.stopPropagation(); onPropagate(v.id); }}
title={`Propagate ${v.patches.length} patch${v.patches.length !== 1 ? 'es' : ''} to children`}
aria-label="Propagate patches to children"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#7c3aed',
flexShrink: 0, borderRadius: 3, fontSize: 12, lineHeight: 1,
}}
>
</button>
)}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
title="Delete branch"
aria-label="Delete branch"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
borderRadius: 3, fontSize: 14, lineHeight: 1,
}}
>
×
</button>
)}
</div>
{open && node.children.map(child => (
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
))}
{open && hasChildren && (
<div style={{
marginLeft: depth > 0 ? 22 : 14,
borderLeft: `1px solid var(--border)`,
paddingLeft: 0,
}}>
{node.children.map((child, i) => (
<Node
key={child.version.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
onDelete={onDelete}
onCopyMarkdown={onCopyMarkdown}
onPropagate={onPropagate}
colorIndex={depth === 0 ? i + 1 : colorIndex}
/>
))}
</div>
)}
</div>
);
}
export default function CVTree({ versions, selectedVersionId, onSelect }: {
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown, onPropagate }: {
versions: Version[]; selectedVersionId: string | null;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
onPropagate?: (id: string) => void;
}) {
const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return (
<div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} onPropagate={onPropagate} />
</div>
);
}

View File

@@ -1,13 +1,46 @@
'use client';
import { useState } from 'react';
import { Patch } from '@/libs/api';
const OP_SYMBOL: Record<string, string> = {
replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+',
};
export default function DiffViewer({ patches }: { patches: Patch[] }) {
if (!patches.length) {
function PatchRow({ p, dim }: { p: Patch; dim?: boolean }) {
return (
<div style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12, opacity: dim ? 0.6 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
)}
</div>
);
}
export default function DiffViewer({
patches, inheritedPatches, ancestorLabel,
}: {
patches: Patch[];
inheritedPatches?: Patch[];
ancestorLabel?: string;
}) {
const [showInherited, setShowInherited] = useState(false);
const hasInherited = (inheritedPatches?.length ?? 0) > 0;
if (!patches.length && !hasInherited) {
return (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No patches identical to parent.
@@ -16,27 +49,37 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => (
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
<div>
{hasInherited && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px' }}
onClick={() => setShowInherited(v => !v)}
>
{showInherited ? '▾' : '▸'} Inherited from ancestors ({inheritedPatches!.length})
</button>
{ancestorLabel && (
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>via {ancestorLabel}</span>
)}
</div>
))}
)}
{showInherited && hasInherited && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18, paddingBottom: 14, borderBottom: '1px dashed var(--border)' }}>
{inheritedPatches!.map(p => <PatchRow key={p.id} p={p} dim />)}
</div>
)}
{patches.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => <PatchRow key={p.id} p={p} />)}
</div>
) : (
<div style={{ padding: '8px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No direct patches on this branch.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import type { InsightsResult } from '@/libs/api';
function Bar({ rate, positive }: { rate: number; positive?: boolean }) {
return (
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.round(rate * 100)}%`,
height: '100%',
background: positive === false ? '#ef4444' : rate >= 0.6 ? '#22c55e' : rate >= 0.4 ? '#f59e0b' : '#94a3b8',
borderRadius: 3,
transition: 'width 0.3s',
}} />
</div>
);
}
function Pct({ v }: { v: number }) {
return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600, color: v >= 0.6 ? '#16a34a' : v >= 0.4 ? '#d97706' : '#6b7280' }}>{Math.round(v * 100)}%</span>;
}
export default function InsightsPanel({ data }: { data: InsightsResult | null }) {
if (!data) return (
<div style={{ padding: '24px 0', color: 'var(--text-faint)', fontSize: 13, textAlign: 'center' }}>
Loading insights
</div>
);
if (!data.has_data) return (
<div style={{ padding: '24px 0', color: 'var(--text-faint)', fontSize: 13 }}>
Not enough data yet. Submit applications and mark outcomes to unlock insights.
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* headline numbers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{[
{ label: 'Total submissions', value: data.total_submissions },
{ label: 'Passed screening', value: data.positive_count },
{ label: 'Screening rate', value: `${Math.round(data.positive_rate * 100)}%` },
].map(({ label, value }) => (
<div key={label} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
<div className="label" style={{ marginBottom: 3 }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{value}</div>
</div>
))}
</div>
{/* operation impact */}
{data.operation_impact.length > 0 && (
<section>
<div className="label" style={{ marginBottom: 8 }}>Patch operation impact</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{data.operation_impact.map(op => (
<div key={op.operation} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, width: 140, flexShrink: 0, color: 'var(--text-muted)' }}>
{op.operation}
</span>
<Bar rate={op.rate} />
<Pct v={op.rate} />
<span style={{ fontSize: 11, color: 'var(--text-faint)', width: 50, textAlign: 'right' }}>
{op.positive}/{op.total}
</span>
</div>
))}
</div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 6 }}>
% of accepted patches of this type in submissions that passed screening.
</p>
</section>
)}
{/* section impact */}
{data.section_impact.length > 0 && (
<section>
<div className="label" style={{ marginBottom: 8 }}>CV section impact</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{data.section_impact.map(s => (
<div key={s.section} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, width: 80, flexShrink: 0, color: 'var(--text-muted)' }}>
{s.section}
</span>
<Bar rate={s.positive_rate} />
<Pct v={s.positive_rate} />
<span style={{ fontSize: 11, color: 'var(--text-faint)', width: 50, textAlign: 'right' }}>
{s.count} edits
</span>
</div>
))}
</div>
</section>
)}
{/* keyword signals */}
{(data.top_positive_keywords.length > 0 || data.top_negative_keywords.length > 0) && (
<section>
<div className="label" style={{ marginBottom: 8 }}>Keyword signals</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 11, color: '#16a34a', fontWeight: 600, marginBottom: 6 }}>Positive signals</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{data.top_positive_keywords.map(k => (
<div key={k.keyword} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{k.keyword}</span>
<span style={{ fontSize: 11, color: '#16a34a' }}>+{k.positive_count} ({k.lift}×)</span>
</div>
))}
</div>
</div>
<div>
<div style={{ fontSize: 11, color: '#dc2626', fontWeight: 600, marginBottom: 6 }}>Negative signals</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{data.top_negative_keywords.length === 0
? <span style={{ fontSize: 12, color: 'var(--text-faint)' }}>None yet</span>
: data.top_negative_keywords.map(k => (
<div key={k.keyword} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{k.keyword}</span>
<span style={{ fontSize: 11, color: '#dc2626' }}>{k.negative_count}×</span>
</div>
))}
</div>
</div>
</div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 6 }}>
Keywords extracted from accepted AI suggestions, split by outcome.
</p>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { previewVersionPdfUrl } from '@/libs/api';
function getToken(): string | null {
if (typeof document === 'undefined') return null;
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=').slice(1).join('=') ?? null;
}
export default function PDFPreview({ documentId, versionId }: { documentId: string; versionId: string }) {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const prevUrl = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const token = getToken();
fetch(previewVersionPdfUrl(documentId, versionId), {
headers: token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {},
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
if (cancelled) return;
if (prevUrl.current) URL.revokeObjectURL(prevUrl.current);
const url = URL.createObjectURL(blob);
prevUrl.current = url;
setSrc(url);
})
.catch(e => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [documentId, versionId]);
useEffect(() => () => { if (prevUrl.current) URL.revokeObjectURL(prevUrl.current); }, []);
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>
Rendering PDF
</div>
);
if (error) return (
<div style={{ padding: 16, fontSize: 12, color: '#dc2626' }}>
Preview unavailable: {error}
</div>
);
if (!src) return null;
return (
<iframe
src={src}
style={{ width: '100%', height: '100%', border: 'none', borderRadius: 4 }}
title="CV Preview"
/>
);
}

View File

@@ -1,6 +1,5 @@
// Empty base: all API calls go to /api/* which Next.js rewrites to the backend.
// The actual backend URL is set via API_BASE_URL env var in next.config.ts (server-side, runtime).
const API = "";
export const IS_DEMO = process.env.NEXT_PUBLIC_DEMO === 'true';
export type StructuredBlock = {
path: string;
@@ -27,6 +26,7 @@ export type Version = {
structured_blocks?: StructuredBlock[] | null;
artifact_docx_key?: string | null;
patches: Patch[];
public_assets: PublicAsset[];
created_at: string;
updated_at: string;
};
@@ -42,6 +42,23 @@ export type Document = {
updated_at: string;
};
export type Suggestion = {
id: string;
target_path: string;
operation: string;
proposed_text?: string | null;
rationale?: string | null;
accepted?: boolean | null;
metadata_json?: { keywords?: string[]; confidence?: number } | null;
};
export type SubmissionStatus =
| 'draft'
| 'tailoring'
| 'pending_review'
| 'published'
| 'archived';
export type Submission = {
id: string;
version_id: string;
@@ -49,7 +66,8 @@ export type Submission = {
role_title: string;
job_url?: string | null;
job_description?: string | null;
status: string;
status: SubmissionStatus;
suggestions: Suggestion[];
created_at: string;
};
@@ -64,12 +82,45 @@ export type PublicAsset = {
created_at: string;
};
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
export type OperationImpact = { operation: string; total: number; positive: number; rate: number };
export type KeywordSignal = { keyword: string; positive_count: number; negative_count: number; lift: number };
export type SectionImpact = { section: string; positive_rate: number; count: number };
export type InsightsResult = {
total_submissions: number;
positive_count: number;
positive_rate: number;
operation_impact: OperationImpact[];
top_positive_keywords: KeywordSignal[];
top_negative_keywords: KeywordSignal[];
section_impact: SectionImpact[];
has_data: boolean;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=')[1];
return token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {};
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
headers: { accept: "application/json", ...init?.headers },
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
});
if (!res.ok) {
if (res.status === 401 && typeof window !== 'undefined') {
document.cookie = 'oidc_token_pub=; max-age=0; path=/';
document.cookie = 'oidc_token=; max-age=0; path=/';
window.location.href = '/login';
}
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
@@ -77,35 +128,55 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
}
export const fetchDocuments = (): Promise<Document[]> =>
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
req<{ items: Document[] }>('/api/v1/documents', { cache: 'no-store' }).then(r => r.items);
export const fetchDocument = (id: string): Promise<Document> =>
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
req<Document>(`/api/v1/documents/${id}`, { cache: 'no-store' });
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
const form = new FormData();
form.append("title", title);
if (description) form.append("description", description);
form.append("file", file);
return req<Document>("/api/v1/documents", { method: "POST", body: form });
form.append('title', title);
if (description) form.append('description', description);
form.append('file', file);
return req<Document>('/api/v1/documents', { method: 'POST', body: form });
}
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
export const previewVersionPdfUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/preview`;
export async function uploadDocxToBranch(documentId: string, versionId: string, file: File): Promise<Version> {
const form = new FormData();
form.append('file', file);
return req<Version>(`/api/v1/documents/${documentId}/versions/${versionId}/upload`, { method: 'POST', body: form });
}
export async function createBranch(
parentVersionId: string,
branchName: string,
versionLabel?: string | null,
patches: Record<string, unknown>[] = [],
): Promise<Version> {
return req<Version>("/api/v1/versions/branches", {
method: "POST",
headers: { "content-type": "application/json" },
return req<Version>('/api/v1/versions/branches', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
});
}
export async function appendPatches(
versionId: string,
patches: Record<string, unknown>[],
): Promise<Version> {
return req<Version>(`/api/v1/versions/${versionId}/patches`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ patches }),
});
}
export async function createSubmission(
versionId: string,
companyName: string,
@@ -113,22 +184,95 @@ export async function createSubmission(
jobUrl?: string | null,
jobDescription?: string | null,
): Promise<Submission> {
return req<Submission>("/api/v1/submissions", {
method: "POST",
headers: { "content-type": "application/json" },
return req<Submission>('/api/v1/submissions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
});
}
export const fetchSubmissions = (versionId?: string): Promise<Submission[]> => {
const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : '';
return req<Submission[]>(`/api/v1/submissions${query}`);
};
export const fetchSubmission = (id: string): Promise<Submission> =>
req<Submission>(`/api/v1/submissions/${id}`);
export async function requestAiSuggestions(
submissionId: string,
jobDescription: string,
focusKeywords: string[] = [],
): Promise<Suggestion[]> {
return req<Suggestion[]>(`/api/v1/submissions/${submissionId}/ai`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ job_description: jobDescription, focus_keywords: focusKeywords }),
});
}
export async function updateSuggestion(
submissionId: string,
suggestionId: string,
accepted: boolean,
): Promise<Suggestion> {
return req<Suggestion>(`/api/v1/submissions/${submissionId}/suggestions/${suggestionId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ accepted }),
});
}
export async function updateSubmissionStatus(
submissionId: string,
status: SubmissionStatus,
): Promise<Submission> {
return req<Submission>(`/api/v1/submissions/${submissionId}/status`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status }),
});
}
export async function publishVersion(
versionId?: string | null,
submissionId?: string | null,
slug?: string | null,
): Promise<PublicAsset> {
return req<PublicAsset>("/api/v1/public/publish", {
method: "POST",
headers: { "content-type": "application/json" },
return req<PublicAsset>('/api/v1/public/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
});
}
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}
export const fetchInsights = (): Promise<InsightsResult> =>
req<InsightsResult>('/api/v1/insights');
export async function deleteVersion(versionId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
method: 'DELETE',
headers: { accept: 'application/json', ...getAuthHeader() },
});
if (!res.ok) {
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
async function verifySession(token: string): Promise<boolean> {
const lastDot = token.lastIndexOf('.');
if (lastDot === -1) return false;
const payload = token.slice(0, lastDot);
const sigHex = token.slice(lastDot + 1);
try {
const key = await globalThis.crypto.subtle.importKey(
'raw', new TextEncoder().encode(SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
const valid = await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
if (!valid) return false;
// check expiry from embedded timestamp (payload = "username:timestamp")
const ts = parseInt(payload.split(':').pop() ?? '0', 10);
return !isNaN(ts) && Date.now() - ts < SESSION_MAX_AGE;
} catch { return false; }
}
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
if (process.env.NEXT_PUBLIC_DEMO === 'true') return NextResponse.next();
const session = req.cookies.get('session')?.value;
const oidc = req.cookies.get('oidc_token')?.value;
if (oidc) return NextResponse.next();
if (session && await verifySession(session)) return NextResponse.next();
return NextResponse.redirect(new URL('/login', req.url));
}
export const config = { matcher: ['/dashboard/:path*'] };

189
dlib/ai/insights.py Normal file
View File

@@ -0,0 +1,189 @@
from __future__ import annotations
import re
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from typing import Literal
STOPWORDS = frozenset(
"a an the and or but in on at to for of with is are was were be been have has"
" had do does did this that these those it its i you he she we they their our"
" your my his her from by into through about as so if then when where which who"
" can will may should would could also just not no more some all any each"
" than other up out off over how what new using use used with well per".split()
)
Outcome = Literal["positive", "negative"] # positive = pending_review / published
@dataclass
class SuggestionRecord:
operation: str
target_path: str
proposed_text: str | None
rationale: str | None
accepted: bool | None
@dataclass
class SubmissionRecord:
status: str
suggestions: list[SuggestionRecord] = field(default_factory=list)
@dataclass
class OperationImpact:
operation: str
total: int
positive: int
rate: float
@dataclass
class KeywordSignal:
keyword: str
positive_count: int
negative_count: int
lift: float # positive_count / max(negative_count, 1)
@dataclass
class SectionImpact:
section: str
positive_rate: float
count: int
@dataclass
class InsightsResult:
total_submissions: int
positive_count: int
positive_rate: float
operation_impact: list[OperationImpact]
top_positive_keywords: list[KeywordSignal]
top_negative_keywords: list[KeywordSignal]
section_impact: list[SectionImpact]
has_data: bool
def _outcome(status: str) -> Outcome | None:
if status in ("pending_review", "published"):
return "positive"
if status == "archived":
return "negative"
return None # draft / tailoring — not enough signal
def _tokens(text: str | None) -> list[str]:
if not text:
return []
return [
t for t in re.findall(r"[a-z][a-z0-9+.-]{1,}", text.lower())
if t not in STOPWORDS and len(t) > 2
]
def _section_prefix(path: str) -> str:
"""heading[1] -> heading, bullet[3] -> bullet, table[1].0-1 -> table"""
return re.match(r"([a-z_]+)", path).group(1) if path else "unknown"
def analyze(submissions: list[SubmissionRecord]) -> InsightsResult:
labeled = [(s, _outcome(s.status)) for s in submissions]
labeled_known = [(s, o) for s, o in labeled if o is not None]
positive_count = sum(1 for _, o in labeled_known if o == "positive")
# operation impact: only accepted suggestions in outcome-labeled submissions
op_positive: Counter[str] = Counter()
op_total: Counter[str] = Counter()
for sub, outcome in labeled_known:
for sug in sub.suggestions:
if sug.accepted is not True:
continue
op_total[sug.operation] += 1
if outcome == "positive":
op_positive[sug.operation] += 1
op_impact = sorted(
[
OperationImpact(
operation=op,
total=total,
positive=op_positive[op],
rate=round(op_positive[op] / total, 3),
)
for op, total in op_total.items()
],
key=lambda x: x.rate,
reverse=True,
)
# keyword signals from accepted-suggestion text in outcome-labeled submissions
kw_pos: Counter[str] = Counter()
kw_neg: Counter[str] = Counter()
for sub, outcome in labeled_known:
bucket = kw_pos if outcome == "positive" else kw_neg
for sug in sub.suggestions:
if sug.accepted is not True:
continue
for t in _tokens(sug.proposed_text) + _tokens(sug.rationale):
bucket[t] += 1
all_kws = set(kw_pos) | set(kw_neg)
signals = [
KeywordSignal(
keyword=kw,
positive_count=kw_pos[kw],
negative_count=kw_neg[kw],
lift=round(kw_pos[kw] / max(kw_neg[kw], 1), 2),
)
for kw in all_kws
if kw_pos[kw] + kw_neg[kw] >= 2 # minimum support
]
top_pos_kw = sorted(
[s for s in signals if s.positive_count > 0],
key=lambda s: (s.lift, s.positive_count),
reverse=True,
)[:8]
top_neg_kw = sorted(
[s for s in signals if s.negative_count > 0],
key=lambda s: (s.negative_count, -s.lift),
reverse=True,
)[:8]
# section impact: group target_path prefix by outcome
sec_pos: Counter[str] = Counter()
sec_total: Counter[str] = Counter()
for sub, outcome in labeled_known:
for sug in sub.suggestions:
if sug.accepted is not True:
continue
sec = _section_prefix(sug.target_path)
sec_total[sec] += 1
if outcome == "positive":
sec_pos[sec] += 1
section_impact = sorted(
[
SectionImpact(
section=sec,
positive_rate=round(sec_pos[sec] / total, 3),
count=total,
)
for sec, total in sec_total.items()
],
key=lambda s: s.positive_rate,
reverse=True,
)
return InsightsResult(
total_submissions=len(submissions),
positive_count=positive_count,
positive_rate=round(positive_count / len(submissions), 3) if submissions else 0.0,
operation_impact=op_impact,
top_positive_keywords=top_pos_kw,
top_negative_keywords=top_neg_kw,
section_impact=section_impact,
has_data=bool(labeled_known),
)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import time
from functools import cached_property
from typing import Any
import httpx
@@ -21,6 +20,16 @@ class TokenValidationError(Exception):
pass
def _normalize_issuer(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
raw = value.strip().rstrip("/")
normalized = raw.replace("/application/o/authorize/", "/application/o/")
normalized = normalized.replace("/application/o/authorize", "/application/o")
normalized = normalized.rstrip("/")
return raw, normalized if normalized != raw else raw
class OidcTokenValidator:
def __init__(
self,
@@ -30,12 +39,16 @@ class OidcTokenValidator:
jwks_url: str | None = None,
disable: bool = False,
) -> None:
self.issuer = issuer
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
self.issuer = raw_issuer
self.audience = audience
self.jwks_url = jwks_url or (
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
self.jwks_url = jwks_url
self.discovery_url = (
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
if (discovery_issuer or raw_issuer)
else None
)
self.disable = disable or not issuer
self.disable = disable or not raw_issuer
self._jwks: dict[str, Any] | None = None
self._jwks_expiry: float = 0
@@ -45,17 +58,22 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer"
)
header = jwt.get_unverified_header(token)
key = await self._get_key(header.get("kid"))
if not key:
raise TokenValidationError("Unable to resolve signing key")
alg = header.get("alg") or "RS256"
jwks = await self._get_jwks()
if not jwks:
raise TokenValidationError("Unable to resolve signing keys")
try:
claims = jwt.decode(
token,
key,
algorithms=[key.get("alg", "RS256")],
audience=self.audience,
issuer=self.issuer,
jwks,
algorithms=[alg],
options={"verify_aud": False, "verify_iss": False},
)
iss = claims.get("iss")
if self.issuer and iss not in (self.issuer, self.issuer + "/"):
# fallback: check if it matches discovery host
if not (iss and iss.startswith(self.issuer.split("/application/")[0])):
raise TokenValidationError(f"Invalid issuer: {iss}")
except JWTError as exc:
raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -69,7 +87,19 @@ class OidcTokenValidator:
roles=roles,
)
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
async def _ensure_jwks_url(self) -> None:
if self.jwks_url or not self.discovery_url:
return
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(self.discovery_url)
response.raise_for_status()
data = response.json()
jwks_uri = data.get("jwks_uri")
if isinstance(jwks_uri, str):
self.jwks_url = jwks_uri
async def _get_jwks(self) -> dict[str, Any] | None:
await self._ensure_jwks_url()
if not self.jwks_url:
return None
if not self._jwks or time.time() > self._jwks_expiry:
@@ -78,12 +108,7 @@ class OidcTokenValidator:
response.raise_for_status()
self._jwks = response.json()
self._jwks_expiry = time.time() + 3600
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
if kid:
for key in keys:
if key.get("kid") == kid:
return key
return keys[0] if keys else None
return self._jwks
def build_validator(

View File

@@ -8,6 +8,8 @@ from .schema import (
from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset
from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [
"StructuredBlock",
@@ -19,4 +21,6 @@ __all__ = [
"summarize_keywords",
"apply_patchset",
"validate_patchset",
"generate_patched_docx",
"docx_bytes_to_pdf",
]

View File

@@ -13,14 +13,9 @@ def validate_patchset(
document: StructuredDocument,
patches: Iterable[PatchPayload],
*,
max_changes: int = 12,
max_growth_ratio: float = 1.45,
) -> None:
patch_list = list(patches)
if len(patch_list) > max_changes:
raise PatchValidationError(
f"Patchset exceeds max changes ({len(patch_list)} > {max_changes})"
)
block_map = {block.path: block for block in document.blocks}
for patch in patch_list:
block = block_map.get(patch.target_path)

76
dlib/cv/docx_export.py Normal file
View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from collections import defaultdict
from io import BytesIO
from docx import Document
from .parser import _detect_block_type
def _path_to_para_map(doc: Document) -> dict[str, int]:
counters: defaultdict[str, int] = defaultdict(int)
result: dict[str, int] = {}
for idx, para in enumerate(doc.paragraphs):
if not para.text.strip():
continue
block_type = _detect_block_type(getattr(para.style, "name", None), para)
counters[block_type] += 1
result[f"{block_type}[{counters[block_type]}]"] = idx
return result
def _replace_para_text(para, new_text: str) -> None:
"""Replace paragraph text preserving the first run's character formatting."""
if not para.runs:
para.add_run(new_text)
return
first = para.runs[0]
for run in para.runs[1:]:
run.text = ""
first.text = new_text
def _remove_paragraph(paragraph) -> None:
p = paragraph._element
p.getparent().remove(p)
def generate_patched_docx(
original_bytes: bytes, structured_blocks: list[dict]
) -> bytes:
"""Return DOCX bytes with text patches from structured_blocks applied.
Compares each block's text against the original paragraph and replaces it
when different. Blocks absent from structured_blocks are removed.
"""
if not structured_blocks:
return original_bytes
doc = Document(BytesIO(original_bytes))
path_map = _path_to_para_map(doc)
original_paths = set(path_map.keys())
patched = {b["path"]: b["text"] for b in structured_blocks}
patched_paths = set(patched.keys())
# Apply text replacements first (indices stay stable)
for path, new_text in patched.items():
idx = path_map.get(path)
if idx is None:
continue
para = doc.paragraphs[idx]
if para.text.strip() != new_text:
_replace_para_text(para, new_text)
# Remove blocks no longer present; process in reverse index order
removed = sorted(
[path_map[p] for p in (original_paths - patched_paths) if p in path_map],
reverse=True,
)
for idx in removed:
_remove_paragraph(doc.paragraphs[idx])
out = BytesIO()
doc.save(out)
return out.getvalue()

21
dlib/cv/pdf_export.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import os
import subprocess
import tempfile
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
with tempfile.TemporaryDirectory() as tmpdir:
docx_path = os.path.join(tmpdir, "cv.docx")
pdf_path = os.path.join(tmpdir, "cv.pdf")
with open(docx_path, "wb") as f:
f.write(docx_bytes)
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
check=True,
capture_output=True,
timeout=60,
)
with open(pdf_path, "rb") as f:
return f.read()

View File

@@ -0,0 +1,148 @@
version: "3.8"
# Standalone deployment — no Traefik/reverse-proxy required.
# Usage: docker compose -f docker-compose.standalone.yml up -d
# Configure via a .env file (copy .env.example and fill in values).
networks:
cvfs-network:
services:
webapp:
container_name: "cvfs-webapp"
build:
context: ./
dockerfile: ./docker/webapp.Dockerfile
args:
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
NEXT_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000}
API_BASE_URL: http://cvfs-backend:8080
environment:
- API_BASE_URL=http://cvfs-backend:8080
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER:-}
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-}
- NEXT_PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
ports:
- "${WEBAPP_PORT:-3000}:3000"
networks:
- cvfs-network
depends_on:
- backend
restart: unless-stopped
backend:
container_name: "cvfs-backend"
build:
context: ./
dockerfile: ./docker/backend-fastapi.Dockerfile
environment:
- BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@cvfs-postgres:5432/resume_branches
- MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000}
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-localhost}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-true}
ports:
- "${BACKEND_PORT:-8080}:8080"
depends_on:
- postgres
- minio
- redis
networks:
- cvfs-network
restart: unless-stopped
worker:
container_name: "cvfs-worker"
build:
context: ./
dockerfile: ./docker/worker.Dockerfile
environment:
- REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PYTHONPATH=/app
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
depends_on:
- redis
- minio
networks:
- cvfs-network
restart: unless-stopped
redis:
container_name: "cvfs-redis"
image: redis:7-alpine
volumes:
- redis_data:/data
networks:
- cvfs-network
restart: unless-stopped
postgres:
image: postgres:15-alpine
container_name: "cvfs-postgres"
environment:
POSTGRES_DB: resume_branches
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- cvfs-network
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: "cvfs-minio"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
ports:
- "${MINIO_CONSOLE_PORT:-9001}:9001"
networks:
- cvfs-network
restart: unless-stopped
create-bucket:
image: minio/mc
container_name: "cvfs-create-bucket"
depends_on:
- minio
networks:
- cvfs-network
entrypoint: >
/bin/sh -c "
sleep 5;
mc alias set myminio http://cvfs-minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
mc mb myminio/$${MINIO_BUCKET:-resume-branches} --ignore-existing;
exit 0;
"
volumes:
redis_data:
postgres_data:
minio_data:

View File

@@ -11,8 +11,19 @@ services:
build:
context: ./
dockerfile: ./docker/webapp.Dockerfile
args:
NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER}
NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
API_BASE_URL: ${API_BASE_URL:-http://cvfs-backend:8080}
environment:
- API_BASE_URL=http://cvfs-backend:8080
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
- NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
networks:
- dokploy-network
- cvfs-network
@@ -33,16 +44,20 @@ services:
- BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
- MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PUBLIC_BASE_URL=https://cv.alves.world
- CV_PUBLIC_DOMAIN=cv.alves.world
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world}
- CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world}
- CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world}
- REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on:
- postgres
- minio
@@ -68,8 +83,8 @@ services:
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches
- MINIO_REGION=us-east-1
- MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PYTHONPATH=/app
@@ -107,8 +122,8 @@ services:
image: minio/minio:latest
container_name: "cvfs-minio"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"

View File

@@ -12,6 +12,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libreoffice-writer \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock requirements.txt ./

View File

@@ -8,6 +8,14 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile
FROM deps AS builder
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
ARG API_BASE_URL
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
ENV API_BASE_URL=${API_BASE_URL}
COPY apps/webapp ./
RUN bun run build

View File

@@ -25,6 +25,7 @@ dependencies = [
"python-multipart",
"pyyaml",
"python-jose[cryptography]",
"reportlab",
"sqlalchemy[asyncio]",
"asyncpg",
"redis",

View File

@@ -19,6 +19,7 @@ pydantic
pydantic-settings
python-jose[cryptography]
python-docx
reportlab
python-multipart
boto3

87
scripts/gen_demo_cv.py Normal file
View File

@@ -0,0 +1,87 @@
"""Generate the static demo CV DOCX used by DEMO mode in the webapp."""
from __future__ import annotations
import sys
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def add_heading(doc: Document, text: str, level: int = 1) -> None:
p = doc.add_heading(text, level=level)
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
def add_bullet(doc: Document, text: str) -> None:
doc.add_paragraph(text, style="List Bullet")
def build(path: Path) -> None:
doc = Document()
# Name / contact
name_para = doc.add_paragraph()
name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = name_para.add_run("Alex Rivera")
run.bold = True
run.font.size = Pt(18)
contact = doc.add_paragraph()
contact.alignment = WD_ALIGN_PARAGRAPH.CENTER
contact.add_run("alex.rivera@email.com · linkedin.com/in/alexrivera · github.com/alexrivera")
doc.add_paragraph() # spacer
# Summary
add_heading(doc, "Summary", level=2)
doc.add_paragraph(
"Software engineer with 5 years of experience building distributed systems and "
"machine learning pipelines at scale. Strong background in Python, Go, and cloud-native "
"architectures. Passionate about developer tooling and open-source contribution."
)
# Experience
add_heading(doc, "Experience", level=2)
add_heading(doc, "Senior Software Engineer — Acme Corp", level=3)
doc.add_paragraph("Jan 2022 Present · San Francisco, CA")
add_bullet(doc, "Led migration of monolithic data pipeline to distributed microservices, reducing p99 latency by 40%.")
add_bullet(doc, "Designed and shipped an internal feature flag system used by 50+ engineers across 3 teams.")
add_bullet(doc, "Mentored 4 junior engineers and ran weekly technical design review sessions.")
add_heading(doc, "Software Engineer — DataFlow Inc", level=3)
doc.add_paragraph("Aug 2019 Dec 2021 · Remote")
add_bullet(doc, "Built real-time streaming ingestion system processing 2M events/day using Kafka and Flink.")
add_bullet(doc, "Developed Python SDK for internal data platform, adopted by 8 product teams.")
add_bullet(doc, "Contributed PyTorch-based anomaly detection model achieving 92% precision on production traffic.")
# Education
add_heading(doc, "Education", level=2)
add_heading(doc, "B.S. Computer Science — State University", level=3)
doc.add_paragraph("Graduated May 2019 · GPA 3.8 / 4.0")
add_bullet(doc, "Senior thesis: Efficient approximate nearest-neighbour search for high-dimensional embeddings.")
# Skills
add_heading(doc, "Skills", level=2)
skills_para = doc.add_paragraph()
skills_para.add_run("Languages: ").bold = True
skills_para.add_run("Python, Go, TypeScript, SQL")
infra_para = doc.add_paragraph()
infra_para.add_run("Infrastructure: ").bold = True
infra_para.add_run("Kubernetes, AWS, GCP, Terraform, Docker")
ml_para = doc.add_paragraph()
ml_para.add_run("ML / Data: ").bold = True
ml_para.add_run("PyTorch, scikit-learn, Spark, Kafka, dbt")
doc.save(path)
print(f"Saved demo CV to {path}")
if __name__ == "__main__":
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("apps/webapp/public/demo-cv.docx")
out.parent.mkdir(parents=True, exist_ok=True)
build(out)

632
uv.lock generated
View File

@@ -2,15 +2,6 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "absl-py"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
@@ -103,6 +94,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
@@ -189,6 +220,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "boto3"
version = "1.42.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/87/1ed88eaa1e814841a37e71fee74c2b74341d14b791c0c6038b7ba914bea1/boto3-1.42.83.tar.gz", hash = "sha256:cc5621e603982cb3145b7f6c9970e02e297a1a0eb94637cc7f7b69d3017640ee", size = 112719, upload-time = "2026-04-03T19:34:21.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/8a066bc8f02937d49783c0b3948ab951d8284e6fde436cab9f359dbd4d93/boto3-1.42.83-py3-none-any.whl", hash = "sha256:544846fdb10585bb7837e409868e8e04c6b372fa04479ba1597ce82cf1242076", size = 140555, upload-time = "2026-04-03T19:34:17.935Z" },
]
[[package]]
name = "botocore"
version = "1.42.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/01/b46a3f8b6e9362258f78f1890db1a96d4ed73214d6a36420dc158dcfd221/botocore-1.42.83.tar.gz", hash = "sha256:34bc8cb64b17ac17f8901f073fe4fc9572a5cac9393a37b2b3ea372a83b87f4a", size = 15140337, upload-time = "2026-04-03T19:34:08.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/97/0d6f50822dc8c1df7f3eadb0bc6822fc0f98f02287c4efc7c7c88fde129a/botocore-1.42.83-py3-none-any.whl", hash = "sha256:ec0c3ecb3772936ed22a3bdda09883b34858933f71004686d460d829bab39d8e", size = 14818388, upload-time = "2026-04-03T19:34:03.333Z" },
]
[[package]]
name = "cachetools"
version = "7.0.3"
@@ -541,29 +600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" },
]
[[package]]
name = "cuda-bindings"
version = "12.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" },
{ url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" },
{ url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" },
]
[[package]]
name = "cuda-pathfinder"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
@@ -619,6 +655,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -745,15 +793,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
]
[[package]]
name = "fsspec"
version = "2026.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
]
[[package]]
name = "gitdb"
version = "4.0.12"
@@ -837,6 +876,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -845,6 +885,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -853,6 +894,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -861,53 +903,13 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "grpcio"
version = "1.78.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" },
{ url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" },
{ url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" },
{ url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" },
{ url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" },
{ url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" },
{ url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" },
{ url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" },
{ url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" },
{ url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" },
{ url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" },
{ url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" },
{ url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" },
{ url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" },
{ url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" },
{ url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" },
{ url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" },
{ url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" },
{ url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" },
{ url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" },
{ url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" },
{ url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" },
{ url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
@@ -1114,6 +1116,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"
@@ -1302,6 +1313,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@@ -1314,15 +1405,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown"
version = "3.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -1550,15 +1632,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/28/fa/b2ba8229b9381e8f6381c1dcae6f4159a7f72349e414ed19cfbbd1817173/MouseInfo-0.1.3.tar.gz", hash = "sha256:2c62fb8885062b8e520a3cce0a297c657adcc08c60952eb05bc8256ef6f7f6e7", size = 10850, upload-time = "2020-03-27T21:20:10.136Z" }
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "mycdp"
version = "1.3.2"
@@ -1628,15 +1701,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
@@ -1698,140 +1762,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "nvidia-cublas-cu12"
version = "12.8.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
]
[[package]]
name = "nvidia-cuda-cupti-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
]
[[package]]
name = "nvidia-cuda-nvrtc-cu12"
version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
]
[[package]]
name = "nvidia-cuda-runtime-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
]
[[package]]
name = "nvidia-cudnn-cu12"
version = "9.10.2.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
]
[[package]]
name = "nvidia-cufft-cu12"
version = "11.3.3.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
]
[[package]]
name = "nvidia-cufile-cu12"
version = "1.13.1.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
]
[[package]]
name = "nvidia-curand-cu12"
version = "10.3.9.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
]
[[package]]
name = "nvidia-cusolver-cu12"
version = "11.7.3.90"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12" },
{ name = "nvidia-cusparse-cu12" },
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
]
[[package]]
name = "nvidia-cusparse-cu12"
version = "12.5.8.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
]
[[package]]
name = "nvidia-cusparselt-cu12"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
]
[[package]]
name = "nvidia-nccl-cu12"
version = "2.27.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" },
]
[[package]]
name = "nvidia-nvjitlink-cu12"
version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
]
[[package]]
name = "nvidia-nvshmem-cu12"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" },
]
[[package]]
name = "nvidia-nvtx-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.40.0"
@@ -2314,6 +2244,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pydeck"
version = "0.9.1"
@@ -2512,6 +2456,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -2521,6 +2478,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-logging-loki"
version = "0.3.1"
@@ -2534,6 +2510,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/ca/1f5660fbda815ed04839d657cab77605337e32c224c03c780cd383c7bcd6/python_logging_loki-0.3.1-py3-none-any.whl", hash = "sha256:8a9131db037fbea3d390089c4c32dbe7ed233944905079615a9fb6f669b0f4e6", size = 7004, upload-time = "2019-11-28T22:34:36.635Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "python-xlib"
version = "0.33"
@@ -2681,6 +2666,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "reportlab"
version = "4.4.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/57/28bfbf0a775b618b6e4d854ef8dd3f5c8988e5d614d8898703502a35f61c/reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487", size = 3714962, upload-time = "2026-02-12T10:45:21.325Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/2e/e1798b8b248e1517e74c6cdf10dd6edd485044e7edf46b5f11ffcc5a0add/reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24", size = 1955400, upload-time = "2026-02-12T10:45:18.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@@ -2836,6 +2834,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
name = "s3transfer"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
]
[[package]]
name = "sbvirtualdisplay"
version = "1.4.0"
@@ -3154,6 +3164,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
@@ -3205,18 +3220,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/e6/412c1e1f200ca8c32ecf10201839183e261ad61ced3ede34a66f6d4be3cf/streamlit-1.55.0-py3-none-any.whl", hash = "sha256:1e4a16449c6131696180f4ddb40ea8c51834e89c2a43e1b0362bc9b1cfd9b415", size = 9075714, upload-time = "2026-03-03T22:25:59.126Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tabcompleter"
version = "1.4.0"
@@ -3238,36 +3241,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "tensorboard"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "absl-py" },
{ name = "grpcio" },
{ name = "markdown" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "protobuf" },
{ name = "setuptools" },
{ name = "tensorboard-data-server" },
{ name = "werkzeug" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" },
]
[[package]]
name = "tensorboard-data-server"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" },
{ url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" },
{ url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -3286,61 +3259,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "torch"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
{ url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" },
{ url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
{ url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
{ url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
{ url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
{ url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
{ url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
{ url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" },
{ url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" },
{ url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" },
{ url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" },
{ url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" },
{ url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" },
]
[[package]]
name = "tornado"
version = "6.5.4"
@@ -3403,18 +3321,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
]
[[package]]
name = "triton"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" },
{ url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" },
{ url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -3463,21 +3369,28 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
{ name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "boto3" },
{ name = "celery", extra = ["redis"] },
{ name = "fastapi" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-logging-loki" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "redis" },
{ name = "reportlab" },
{ name = "requests" },
{ name = "seleniumbase" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "streamlit" },
{ name = "tensorboard" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -3498,22 +3411,29 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "anthropic" },
{ name = "asyncpg" },
{ name = "beautifulsoup4" },
{ name = "boto3" },
{ name = "celery", extras = ["redis"] },
{ name = "fastapi" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "httpx" },
{ name = "mlflow", marker = "extra == 'mlflow'" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-docx" },
{ name = "python-dotenv" },
{ name = "python-jose", extras = ["cryptography"] },
{ name = "python-logging-loki" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "redis" },
{ name = "reportlab" },
{ name = "requests" },
{ name = "seleniumbase" },
{ name = "sqlalchemy", extras = ["asyncio"] },
{ name = "streamlit" },
{ name = "tensorboard" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "uvicorn", extras = ["standard"] },
]