mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-15 19:03:38 +00:00
Compare commits
37 Commits
copilot/fi
...
1e57ec7642
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e57ec7642 | ||
|
|
52eab5b6e4 | ||
|
|
f625dead76 | ||
|
|
97ee914b1b | ||
| a21f14ea87 | |||
|
|
61430317f4 | ||
|
|
03187ab456 | ||
|
|
9b5add1cdf | ||
|
|
3838bfe51a | ||
|
|
9c57dcaedb | ||
|
|
5680465e98 | ||
| 41352b05ec | |||
| 04882b2652 | |||
|
|
6ab7e35a6a | ||
|
|
ba4fedd1fa | ||
|
|
615d1bdb9e | ||
| 0f32d46404 | |||
| 2f112efe75 | |||
| 2de579ea59 | |||
| 5facc4b7a5 | |||
| 07fbfbbd85 | |||
| 3f0ac81357 | |||
| 747c129f0b | |||
| 3eb2825a86 | |||
|
|
ca6e25bb31 | ||
|
|
aa419cde0d | ||
| 96a1f1683a | |||
| d126315fa5 | |||
| c9914191d8 | |||
| 15d5ef6ac6 | |||
| 95c81955c9 | |||
| 3b490dedfc | |||
| 8e74f85178 | |||
|
|
700ff5ee0d | ||
|
|
bdf9b25544 | ||
|
|
1b11cdf25c | ||
|
|
1a261be792 |
105
.env.example
105
.env.example
@@ -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
1
.gitignore
vendored
@@ -9,3 +9,4 @@ logs/
|
||||
FLAT.xml
|
||||
**/package-lock.json
|
||||
.nx/
|
||||
**/*.db
|
||||
|
||||
292
README.md
292
README.md
@@ -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.
|
||||

|
||||

|
||||

|
||||
[](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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,11 +5,18 @@ 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, delete_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 generate_patched_docx
|
||||
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
||||
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
@@ -21,7 +28,7 @@ async def list_user_documents(
|
||||
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)
|
||||
|
||||
|
||||
@@ -34,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")
|
||||
@@ -60,6 +67,50 @@ async def download_version_docx(
|
||||
)
|
||||
|
||||
|
||||
@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(...),
|
||||
@@ -75,7 +126,7 @@ 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)
|
||||
@@ -87,3 +138,20 @@ async def delete_user_document(
|
||||
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})
|
||||
|
||||
41
apps/backend/fastapi/app/api/routes/insights.py
Normal file
41
apps/backend/fastapi/app/api/routes/insights.py
Normal 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,
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from app.schemas import (
|
||||
AiSuggestionRequest,
|
||||
SubmissionCreateRequest,
|
||||
SubmissionResponse,
|
||||
SubmissionStatusUpdateRequest,
|
||||
SuggestionResponse,
|
||||
SuggestionUpdateRequest,
|
||||
)
|
||||
@@ -16,8 +17,10 @@ from app.services.submissions import (
|
||||
get_submission,
|
||||
list_submissions,
|
||||
request_ai_suggestions,
|
||||
update_submission_status,
|
||||
update_suggestion,
|
||||
)
|
||||
from app.models import SubmissionStatus
|
||||
from dlib.auth import AuthenticatedUser
|
||||
|
||||
|
||||
@@ -40,7 +43,9 @@ async def get_submission_endpoint(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id)
|
||||
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)
|
||||
@@ -66,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,
|
||||
@@ -85,7 +108,9 @@ async def request_submissions_ai(
|
||||
return [SuggestionResponse.model_validate(item) for item in suggestions]
|
||||
|
||||
|
||||
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
|
||||
@router.patch(
|
||||
"/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse
|
||||
)
|
||||
async def update_suggestion_endpoint(
|
||||
submission_id: str,
|
||||
suggestion_id: str,
|
||||
|
||||
@@ -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, delete_version
|
||||
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,17 @@ 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)
|
||||
@@ -44,3 +52,26 @@ async def delete_version_branch(
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -52,9 +52,21 @@ class CvVersion(Base, IdentifierMixin, TimestampMixin):
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,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"
|
||||
@@ -126,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))
|
||||
@@ -138,9 +152,14 @@ 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,
|
||||
"PublicAssetView",
|
||||
back_populates="public_asset",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +175,9 @@ class PublicAssetView(Base, IdentifierMixin):
|
||||
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")
|
||||
public_asset: Mapped[PublicAsset] = relationship(
|
||||
"PublicAsset", back_populates="views"
|
||||
)
|
||||
|
||||
|
||||
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
|
||||
|
||||
@@ -4,12 +4,14 @@ from .cv import (
|
||||
DocumentCreateResult,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
PatchApplyRequest,
|
||||
PublicAssetAnalyticsResponse,
|
||||
PublicAssetLookupResponse,
|
||||
PublicAssetResponse,
|
||||
PublishRequest,
|
||||
SubmissionCreateRequest,
|
||||
SubmissionResponse,
|
||||
SubmissionStatusUpdateRequest,
|
||||
SuggestionResponse,
|
||||
SuggestionUpdateRequest,
|
||||
VersionResponse,
|
||||
@@ -21,8 +23,10 @@ __all__ = [
|
||||
"DocumentCreateResult",
|
||||
"VersionResponse",
|
||||
"BranchCreateRequest",
|
||||
"PatchApplyRequest",
|
||||
"SubmissionCreateRequest",
|
||||
"SubmissionResponse",
|
||||
"SubmissionStatusUpdateRequest",
|
||||
"AiSuggestionRequest",
|
||||
"SuggestionResponse",
|
||||
"SuggestionUpdateRequest",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
34
apps/backend/fastapi/app/schemas/insights.py
Normal file
34
apps/backend/fastapi/app/schemas/insights.py
Normal 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
|
||||
@@ -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,25 +23,32 @@ 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)
|
||||
|
||||
stmt = (
|
||||
select(CvDocument)
|
||||
.where(CvDocument.id == doc.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()
|
||||
@@ -51,7 +58,12 @@ async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocumen
|
||||
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)
|
||||
@@ -64,7 +76,12 @@ 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()
|
||||
@@ -76,6 +93,14 @@ async def delete_document(
|
||||
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
|
||||
|
||||
37
apps/backend/fastapi/app/services/insights.py
Normal file
37
apps/backend/fastapi/app/services/insights.py
Normal 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)
|
||||
@@ -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(
|
||||
@@ -133,6 +132,21 @@ async def update_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:
|
||||
@@ -155,7 +169,10 @@ async def _get_submission_for_owner(
|
||||
.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()
|
||||
|
||||
@@ -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(
|
||||
@@ -78,12 +102,130 @@ async def create_branch(
|
||||
stmt_refresh = (
|
||||
select(CvVersion)
|
||||
.where(CvVersion.id == new_version.id)
|
||||
.options(selectinload(CvVersion.patches))
|
||||
.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:
|
||||
@@ -100,10 +242,15 @@ async def delete_version(
|
||||
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_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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
BIN
apps/webapp/public/demo-cv.docx
Normal file
BIN
apps/webapp/public/demo-cv.docx
Normal file
Binary file not shown.
@@ -1,20 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createHmac } from 'crypto';
|
||||
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 body = await req.json().catch(() => ({}));
|
||||
const { username, password } = body as Record<string, string>;
|
||||
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
|
||||
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 });
|
||||
|
||||
@@ -4,5 +4,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -5,32 +5,25 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
|
||||
const backend = process.env.API_BASE_URL ?? 'http://localhost:9812';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${backend}/api/v1/public/${slug}`, {
|
||||
cache: 'no-store'
|
||||
const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return new NextResponse('CV not found', { status: 404 });
|
||||
}
|
||||
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 data = await res.json();
|
||||
if (!data.asset || !data.asset.artifact_key) {
|
||||
return new NextResponse('CV not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Construct MinIO public URL
|
||||
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production'
|
||||
? 'https://storage.cv.alves.world'
|
||||
: 'http://localhost:9900');
|
||||
|
||||
const bucket = process.env.MINIO_BUCKET || 'resume-branches';
|
||||
const downloadUrl = `${storageHost}/${bucket}/${data.asset.artifact_key}`;
|
||||
|
||||
return NextResponse.redirect(downloadUrl);
|
||||
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 });
|
||||
|
||||
164
apps/webapp/src/app/dashboard/demo-data.ts
Normal file
164
apps/webapp/src/app/dashboard/demo-data.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
@@ -1,17 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import CVTree from '@/components/cv/CVTree';
|
||||
import CVTree, { versionToMarkdown } from '@/components/cv/CVTree';
|
||||
import DiffViewer from '@/components/cv/DiffViewer';
|
||||
import InsightsPanel from '@/components/cv/InsightsPanel';
|
||||
import PDFPreview from '@/components/cv/PDFPreview';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
appendPatches,
|
||||
createBranch, createSubmission, deleteDocument, deleteVersion,
|
||||
Document, downloadVersionUrl,
|
||||
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||
fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||
InsightsResult,
|
||||
IS_DEMO,
|
||||
Patch,
|
||||
publishVersion, PublicAsset, PublicAssetAnalytics,
|
||||
requestAiSuggestions,
|
||||
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
||||
Submission,
|
||||
SubmissionStatus,
|
||||
StructuredBlock,
|
||||
Suggestion,
|
||||
updateSubmissionStatus,
|
||||
updateSuggestion,
|
||||
uploadDocument,
|
||||
uploadDocxToBranch,
|
||||
Version,
|
||||
} from '@/libs/api';
|
||||
import {
|
||||
DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS,
|
||||
} from './demo-data';
|
||||
|
||||
// ── diff propagation helpers ──────────────────────────────────────────────────
|
||||
|
||||
function getAncestorChain(versions: Version[], versionId: string): Version[] {
|
||||
const map = new Map(versions.map(v => [v.id, v]));
|
||||
const chain: Version[] = [];
|
||||
let cur = map.get(versionId);
|
||||
while (cur) { chain.unshift(cur); cur = cur.parent_version_id ? map.get(cur.parent_version_id) : undefined; }
|
||||
return chain;
|
||||
}
|
||||
|
||||
function computeInheritedPatches(versions: Version[], versionId: string): Patch[] {
|
||||
return getAncestorChain(versions, versionId).slice(0, -1).flatMap(v => v.patches);
|
||||
}
|
||||
|
||||
function getDescendants(versions: Version[], versionId: string): Version[] {
|
||||
const children = versions.filter(v => v.parent_version_id === versionId);
|
||||
return children.flatMap(c => [c, ...getDescendants(versions, c.id)]);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,12 +55,32 @@ function fmt(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const SUBMISSION_STATUS_LABELS: Record<SubmissionStatus, string> = {
|
||||
draft: 'Draft',
|
||||
tailoring: 'Tailoring',
|
||||
pending_review: 'Passed screening',
|
||||
published: 'Submitted',
|
||||
archived: 'Closed',
|
||||
};
|
||||
|
||||
const SUBMISSION_STATUS_OPTIONS: Array<{ value: SubmissionStatus; label: string }> = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'tailoring', label: 'Tailoring' },
|
||||
{ value: 'published', label: 'Submitted' },
|
||||
{ value: 'pending_review', label: 'Passed screening' },
|
||||
{ value: 'archived', label: 'Closed' },
|
||||
];
|
||||
|
||||
function isSubmittedStatus(status: SubmissionStatus) {
|
||||
return status === 'published' || status === 'pending_review' || status === 'archived';
|
||||
}
|
||||
|
||||
function statusBadge(status: SubmissionStatus) {
|
||||
const cls = ({
|
||||
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
|
||||
published: 'badge-public', archived: 'badge-closed',
|
||||
} as Record<string, string>)[status] ?? 'badge-draft';
|
||||
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
|
||||
} as Record<SubmissionStatus, string>)[status] ?? 'badge-draft';
|
||||
return <span className={`badge ${cls}`}>{SUBMISSION_STATUS_LABELS[status] ?? status.replace('_', ' ')}</span>;
|
||||
}
|
||||
|
||||
// ── modals ────────────────────────────────────────────────────────────────────
|
||||
@@ -40,8 +96,14 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
|
||||
const submit = async () => {
|
||||
if (!title.trim() || !file) { setError('Title and file required.'); return; }
|
||||
setLoading(true); setError('');
|
||||
try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); }
|
||||
try {
|
||||
const doc = await uploadDocument(title.trim(), desc.trim() || null, file);
|
||||
await Promise.resolve(onDone(doc));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,8 +151,14 @@ function BranchModal({
|
||||
const submit = async () => {
|
||||
if (!name.trim()) { setError('Branch name required.'); return; }
|
||||
setLoading(true); setError('');
|
||||
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||
try {
|
||||
const v = await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[]);
|
||||
await Promise.resolve(onDone(v));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -136,8 +204,14 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
|
||||
const submit = async () => {
|
||||
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
|
||||
setLoading(true); setError('');
|
||||
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||
try {
|
||||
const s = await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null);
|
||||
await Promise.resolve(onDone(s));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -177,8 +251,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const asset = await publishVersion(version.id, null, slug.trim() || null);
|
||||
onDone(asset);
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
||||
await Promise.resolve(onDone(asset));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -303,20 +381,27 @@ function ContentTab({
|
||||
// ── submissions tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function SubmissionsTab({
|
||||
submissions, loading, versionId,
|
||||
onNewSubmission, onRefresh,
|
||||
submissions, loading,
|
||||
onNewSubmission, onRefresh, onStatusChange,
|
||||
}: {
|
||||
submissions: Submission[];
|
||||
loading: boolean;
|
||||
versionId: string;
|
||||
onNewSubmission: () => void;
|
||||
onRefresh: () => void;
|
||||
onStatusChange: (submissionId: string, status: SubmissionStatus) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<string | null>(null);
|
||||
const [aiJd, setAiJd] = useState<Record<string, string>>({});
|
||||
const [suggestions, setSuggestions] = useState<Record<string, Suggestion[]>>({});
|
||||
|
||||
const submittedCount = submissions.filter(s => isSubmittedStatus(s.status)).length;
|
||||
const passedScreeningCount = submissions.filter(s => s.status === 'pending_review').length;
|
||||
const successRate = submittedCount > 0
|
||||
? Math.round((passedScreeningCount / submittedCount) * 100)
|
||||
: 0;
|
||||
|
||||
const loadAi = async (s: Submission) => {
|
||||
const jd = aiJd[s.id] ?? s.job_description ?? '';
|
||||
if (!jd.trim()) return;
|
||||
@@ -339,6 +424,19 @@ function SubmissionsTab({
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const changeStatus = async (sub: Submission, nextStatus: SubmissionStatus) => {
|
||||
if (sub.status === nextStatus) return;
|
||||
setStatusLoading(sub.id);
|
||||
try {
|
||||
const updated = await updateSubmissionStatus(sub.id, nextStatus);
|
||||
onStatusChange(updated.id, updated.status);
|
||||
} catch {
|
||||
// ignore for now
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>Loading…</div>;
|
||||
|
||||
return (
|
||||
@@ -348,6 +446,21 @@ function SubmissionsTab({
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||
<div className="label" style={{ marginBottom: 4 }}>Submitted</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{submittedCount}</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||
<div className="label" style={{ marginBottom: 4 }}>Passed screening</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{passedScreeningCount}</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||
<div className="label" style={{ marginBottom: 4 }}>Success rate</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{successRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 && (
|
||||
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||
No submissions yet. Create one to track a job application and get AI tailoring suggestions.
|
||||
@@ -388,6 +501,20 @@ function SubmissionsTab({
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div className="label" style={{ marginBottom: 6 }}>Application stage</div>
|
||||
<select
|
||||
value={s.status}
|
||||
onChange={e => changeStatus(s, e.target.value as SubmissionStatus)}
|
||||
disabled={statusLoading === s.id}
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
{SUBMISSION_STATUS_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* AI tailoring */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div className="label" style={{ marginBottom: 6 }}>AI tailoring</div>
|
||||
@@ -471,7 +598,7 @@ function SubmissionsTab({
|
||||
// ── main dashboard ────────────────────────────────────────────────────────────
|
||||
|
||||
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
|
||||
type Tab = 'content' | 'patches' | 'submissions';
|
||||
type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
@@ -480,27 +607,55 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
|
||||
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
|
||||
const [publishedAnalytics, setPublishedAnalytics] = useState<Record<string, PublicAssetAnalytics>>({});
|
||||
const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('content');
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||
const [allSubmissions, setAllSubmissions] = useState<Submission[]>([]);
|
||||
const [subsLoading, setSubsLoading] = useState(false);
|
||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||
const [applyLoading, setApplyLoading] = useState(false);
|
||||
const [applyError, setApplyError] = useState('');
|
||||
const [insights, setInsights] = useState<InsightsResult | null>(null);
|
||||
const [uploadBranchLoading, setUploadBranchLoading] = useState(false);
|
||||
const [uploadBranchError, setUploadBranchError] = useState('');
|
||||
const [propagateLoading, setPropagateLoading] = useState(false);
|
||||
const [propagateError, setPropagateError] = useState('');
|
||||
const uploadBranchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
.then(d => {
|
||||
if (IS_DEMO) {
|
||||
setDocs(DEMO_DOCUMENTS);
|
||||
setAllSubmissions(DEMO_SUBMISSIONS);
|
||||
setSelectedDocId(DEMO_DOC_ID);
|
||||
setInsights(DEMO_INSIGHTS);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
Promise.all([fetchDocuments(), fetchSubmissions().catch(() => [])])
|
||||
.then(([d, allSubs]) => {
|
||||
setDocs(d);
|
||||
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
|
||||
setAllSubmissions(allSubs);
|
||||
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(null); }
|
||||
})
|
||||
.catch(() => setError('Failed to load documents. Make sure the backend is running.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_DEMO || !selectedDocId) return;
|
||||
fetchInsights().then(setInsights).catch(() => setInsights(null));
|
||||
}, [selectedDocId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingEdits(new Map());
|
||||
setApplyError('');
|
||||
setApplyLoading(false);
|
||||
setPublishedAnalytics({});
|
||||
setRecentlyPublishedSlug(null);
|
||||
setPropagateError('');
|
||||
}, [selectedVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,10 +669,36 @@ export default function Dashboard() {
|
||||
|
||||
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
||||
const selectedDocVersionIds = new Set((selectedDoc?.versions ?? []).map(v => v.id));
|
||||
const selectedDocSubmissions = allSubmissions.filter(s => selectedDocVersionIds.has(s.version_id));
|
||||
const selectedDocSubmittedCount = selectedDocSubmissions.filter(s => isSubmittedStatus(s.status)).length;
|
||||
const selectedDocPassedScreeningCount = selectedDocSubmissions.filter(s => s.status === 'pending_review').length;
|
||||
const selectedDocSuccessRate = selectedDocSubmittedCount > 0
|
||||
? Math.round((selectedDocPassedScreeningCount / selectedDocSubmittedCount) * 100)
|
||||
: 0;
|
||||
const publishedAssets = selectedVersion?.public_assets ?? [];
|
||||
const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
const resolveAssetUrl = (asset: PublicAsset): string => {
|
||||
if (asset.url) return asset.url;
|
||||
if (publicBaseUrl) return `${publicBaseUrl}/cv/${asset.slug}`;
|
||||
return `/cv/${asset.slug}`;
|
||||
};
|
||||
|
||||
const loadPublishedAnalytics = async (slug: string) => {
|
||||
try {
|
||||
const stats = await fetchPublicAssetAnalytics(slug);
|
||||
setPublishedAnalytics(prev => ({ ...prev, [slug]: stats }));
|
||||
} catch {
|
||||
// swallow for now; UI button can be retried
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDocs = async () => {
|
||||
const fresh = await fetchDocuments().catch(() => docs);
|
||||
setDocs(fresh);
|
||||
refreshAllSubs();
|
||||
return fresh;
|
||||
};
|
||||
|
||||
@@ -526,10 +707,14 @@ export default function Dashboard() {
|
||||
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
|
||||
};
|
||||
|
||||
function refreshAllSubs() {
|
||||
fetchSubmissions().then(setAllSubmissions).catch(() => { });
|
||||
}
|
||||
|
||||
const onUploadDone = (doc: Document) => {
|
||||
setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]);
|
||||
setSelectedDocId(doc.id);
|
||||
setSelectedVersionId(doc.root_version_id ?? null);
|
||||
setSelectedVersionId(null);
|
||||
setModal(null);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
@@ -544,17 +729,73 @@ export default function Dashboard() {
|
||||
|
||||
const onSubmissionDone = (s: Submission) => {
|
||||
setSubmissions(prev => [s, ...prev]);
|
||||
setAllSubmissions(prev => [s, ...prev]);
|
||||
setModal(null);
|
||||
setActiveTab('submissions');
|
||||
};
|
||||
|
||||
const handleSubmissionStatusChange = (submissionId: string, status: SubmissionStatus) => {
|
||||
setSubmissions(prev => prev.map(s => (s.id === submissionId ? { ...s, status } : s)));
|
||||
setAllSubmissions(prev => prev.map(s => (s.id === submissionId ? { ...s, status } : s)));
|
||||
};
|
||||
|
||||
const stageEdit = (path: string, old_value: string, new_value: string) => {
|
||||
setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value }));
|
||||
};
|
||||
|
||||
const discardEdits = () => setPendingEdits(new Map());
|
||||
|
||||
const handleUploadToBranch = async (file: File) => {
|
||||
if (!selectedDocId || !selectedVersionId) return;
|
||||
setUploadBranchLoading(true);
|
||||
setUploadBranchError('');
|
||||
try {
|
||||
await uploadDocxToBranch(selectedDocId, selectedVersionId, file);
|
||||
await refreshDocs();
|
||||
} catch (e: unknown) {
|
||||
setUploadBranchError(e instanceof Error ? e.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploadBranchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = (versionId: string) => {
|
||||
if (!selectedDoc) return;
|
||||
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
|
||||
const sections = selectedDoc.versions
|
||||
.filter(v => v.id === versionId || selectedDoc.versions.some(x => x.id === versionId))
|
||||
.map(v => versionToMarkdown(v, v.parent_version_id ? (versionMap.get(v.parent_version_id ?? '')?.branch_name) : undefined));
|
||||
const md = `# ${selectedDoc.title}\n\n${sections.join('\n\n---\n\n')}`;
|
||||
navigator.clipboard.writeText(md).catch(() => {});
|
||||
};
|
||||
|
||||
const handleCopyBranchMarkdown = (versionId: string) => {
|
||||
if (!selectedDoc) return;
|
||||
const version = selectedDoc.versions.find(v => v.id === versionId);
|
||||
if (!version) return;
|
||||
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
|
||||
const parentName = version.parent_version_id ? versionMap.get(version.parent_version_id)?.branch_name : undefined;
|
||||
const md = `# ${selectedDoc.title}\n\n${versionToMarkdown(version, parentName)}`;
|
||||
navigator.clipboard.writeText(md).catch(() => {});
|
||||
};
|
||||
|
||||
const applyStagedEdits = async () => {
|
||||
if (!selectedVersionId || !stagedPatches.length) return;
|
||||
setApplyLoading(true);
|
||||
setApplyError('');
|
||||
try {
|
||||
await appendPatches(selectedVersionId, stagedPatches as Record<string, unknown>[]);
|
||||
await refreshDocs();
|
||||
setPendingEdits(new Map());
|
||||
} catch (e: unknown) {
|
||||
setApplyError(e instanceof Error ? e.message : 'Failed to apply edits');
|
||||
} finally {
|
||||
setApplyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (docId: string) => {
|
||||
if (IS_DEMO) return;
|
||||
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||
try {
|
||||
await deleteDocument(docId);
|
||||
@@ -562,7 +803,7 @@ export default function Dashboard() {
|
||||
setDocs(updated);
|
||||
if (selectedDocId === docId) {
|
||||
setSelectedDocId(updated[0]?.id ?? null);
|
||||
setSelectedVersionId(updated[0]?.root_version_id ?? null);
|
||||
setSelectedVersionId(null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||
@@ -570,7 +811,7 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
const handleDeleteVersion = async (versionId: string) => {
|
||||
const version = selectedDoc?.versions.find(v => v.id === versionId);
|
||||
if (IS_DEMO) return;
|
||||
const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
|
||||
const msg = hasChildren
|
||||
? 'Delete this branch and all its sub-branches? This cannot be undone.'
|
||||
@@ -580,14 +821,40 @@ export default function Dashboard() {
|
||||
await deleteVersion(versionId);
|
||||
const fresh = await refreshDocs();
|
||||
if (selectedVersionId === versionId) {
|
||||
const doc = fresh.find(d => d.id === selectedDocId);
|
||||
setSelectedVersionId(doc?.root_version_id ?? null);
|
||||
const doc = fresh.find(d => d.id === selectedDocId) ?? null;
|
||||
if (!doc) setSelectedDocId(null);
|
||||
setSelectedVersionId(null);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropagate = async (versionId: string) => {
|
||||
if (IS_DEMO || !selectedDoc) return;
|
||||
const version = selectedDoc.versions.find(v => v.id === versionId);
|
||||
if (!version || !version.patches.length) return;
|
||||
const descendants = getDescendants(selectedDoc.versions, versionId);
|
||||
if (!descendants.length) return;
|
||||
const msg = `Propagate ${version.patches.length} patch${version.patches.length !== 1 ? 'es' : ''} from "${version.branch_name}" to ${descendants.length} downstream branch${descendants.length !== 1 ? 'es' : ''}?`;
|
||||
if (!confirm(msg)) return;
|
||||
setPropagateLoading(true);
|
||||
setPropagateError('');
|
||||
try {
|
||||
await Promise.all(descendants.map(d =>
|
||||
appendPatches(d.id, version.patches.map(p => ({
|
||||
target_path: p.target_path, operation: p.operation,
|
||||
old_value: p.old_value ?? undefined, new_value: p.new_value ?? undefined,
|
||||
})) as Record<string, unknown>[])
|
||||
));
|
||||
await refreshDocs();
|
||||
} catch (e: unknown) {
|
||||
setPropagateError(e instanceof Error ? e.message : 'Propagation failed');
|
||||
} finally {
|
||||
setPropagateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectVersion = (id: string) => {
|
||||
setSelectedVersionId(id);
|
||||
setActiveTab('content');
|
||||
@@ -622,12 +889,21 @@ export default function Dashboard() {
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
|
||||
+ Upload CV
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }} onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
{IS_DEMO && (
|
||||
<span style={{ fontSize: 11, padding: '2px 10px', background: '#7c3aed', color: '#fff', borderRadius: 9999, fontWeight: 600, letterSpacing: '0.04em' }}>
|
||||
DEMO
|
||||
</span>
|
||||
)}
|
||||
{!IS_DEMO && (
|
||||
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
|
||||
+ Upload CV
|
||||
</button>
|
||||
)}
|
||||
{!IS_DEMO && (
|
||||
<button className="btn btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }} onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -665,7 +941,7 @@ export default function Dashboard() {
|
||||
onMouseLeave={() => setDocHovered(null)}
|
||||
onClick={() => {
|
||||
setSelectedDocId(d.id);
|
||||
setSelectedVersionId(d.root_version_id ?? null);
|
||||
setSelectedVersionId(null);
|
||||
setActiveTab('content');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
@@ -710,6 +986,8 @@ export default function Dashboard() {
|
||||
selectedVersionId={selectedVersionId}
|
||||
onSelect={selectVersion}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onCopyMarkdown={handleCopyBranchMarkdown}
|
||||
onPropagate={handlePropagate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -720,8 +998,65 @@ export default function Dashboard() {
|
||||
{/* main panel */}
|
||||
<div className="main-panel">
|
||||
{!selectedVersion && !loading && (
|
||||
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||
Select a branch to view details.
|
||||
<div style={{ padding: '16px 20px', overflow: 'auto' }}>
|
||||
{selectedDoc ? (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, background: '#fff', padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>Dashboard overview</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{selectedDoc.title}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{selectedDoc.versions.length} version{selectedDoc.versions.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: 'var(--text-faint)', fontSize: 13, marginBottom: 10 }}>
|
||||
Select a branch to view details.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>Submissions</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSubmissions.length}</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>Submitted</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSubmittedCount}</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>Passed screening</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocPassedScreeningCount}</div>
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
|
||||
<div className="label" style={{ marginBottom: 3 }}>Success rate</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSuccessRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)', maxHeight: 320, overflow: 'auto' }}>
|
||||
<div className="label" style={{ padding: '8px 10px 4px' }}>Full branch tree</div>
|
||||
<CVTree
|
||||
versions={selectedDoc.versions}
|
||||
selectedVersionId={selectedVersionId}
|
||||
onSelect={selectVersion}
|
||||
onCopyMarkdown={handleCopyBranchMarkdown}
|
||||
onPropagate={handlePropagate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{insights?.has_data && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="label" style={{ marginBottom: 8 }}>NLP insights</div>
|
||||
<InsightsPanel data={insights} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||
Select a resume to view details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -754,45 +1089,114 @@ export default function Dashboard() {
|
||||
|
||||
{/* action buttons */}
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>
|
||||
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
|
||||
{selectedVersion.artifact_docx_key && selectedDoc && (
|
||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>}
|
||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>}
|
||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>}
|
||||
{!IS_DEMO && selectedVersion.patches.length > 0 && selectedDoc && getDescendants(selectedDoc.versions, selectedVersion.id).length > 0 && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: '#7c3aed', borderColor: '#c4b5fd' }}
|
||||
onClick={() => handlePropagate(selectedVersion.id)}
|
||||
disabled={propagateLoading}
|
||||
title="Apply this version's patches to all descendant branches"
|
||||
>
|
||||
{propagateLoading ? 'Propagating…' : 'Propagate ↓'}
|
||||
</button>
|
||||
)}
|
||||
{IS_DEMO && (
|
||||
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
|
||||
↓ DOCX
|
||||
</a>
|
||||
)}
|
||||
{!IS_DEMO && selectedVersion.artifact_docx_key && selectedDoc && (
|
||||
<a href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)} download className="btn btn-ghost">
|
||||
↓ DOCX
|
||||
</a>
|
||||
)}
|
||||
{!IS_DEMO && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => uploadBranchRef.current?.click()}
|
||||
disabled={uploadBranchLoading}
|
||||
title="Upload a new DOCX to this branch — diff is computed automatically"
|
||||
>
|
||||
{uploadBranchLoading ? 'Uploading…' : '↑ DOCX'}
|
||||
</button>
|
||||
<input
|
||||
ref={uploadBranchRef}
|
||||
type="file"
|
||||
accept=".docx"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUploadToBranch(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{publishedAsset && (
|
||||
{sortedPublishedAssets.length > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 10,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
|
||||
{publishedAnalytics !== null && (
|
||||
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
|
||||
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#166534', fontWeight: 500 }}>Published variants ({sortedPublishedAssets.length})</span>
|
||||
{recentlyPublishedSlug && (
|
||||
<span style={{ fontSize: 11, color: '#14532d', background: '#dcfce7', padding: '1px 8px', borderRadius: 9999 }}>
|
||||
Latest: {recentlyPublishedSlug}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
|
||||
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
|
||||
>
|
||||
↻ stats
|
||||
</button>
|
||||
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
Share link
|
||||
</a>
|
||||
<span style={{ color: '#bbf7d0' }}>|</span>
|
||||
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
View PDF
|
||||
</a>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sortedPublishedAssets.map(asset => {
|
||||
const stats = publishedAnalytics[asset.slug];
|
||||
return (
|
||||
<div key={asset.id} style={{ border: '1px solid #bbf7d0', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: '#166534', fontSize: 12 }}>{asset.slug}</span>
|
||||
<span style={{ fontSize: 11, color: '#166534' }}>{fmt(asset.created_at)}</span>
|
||||
{recentlyPublishedSlug === asset.slug && (
|
||||
<span style={{ fontSize: 10, color: '#14532d', background: '#dcfce7', padding: '1px 6px', borderRadius: 9999 }}>New</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<a href={resolveAssetUrl(asset)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
Share link
|
||||
</a>
|
||||
<span style={{ color: '#bbf7d0' }}>|</span>
|
||||
<a href={getPublicPdfUrl(asset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
|
||||
View PDF
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11, color: '#166534' }}>
|
||||
{stats
|
||||
? (
|
||||
<>
|
||||
{stats.view_count} view{stats.view_count !== 1 ? 's' : ''}
|
||||
{stats.last_viewed_at && (
|
||||
<> · last {fmt(stats.last_viewed_at)}</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: 'No stats yet'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
onClick={() => loadPublishedAnalytics(asset.slug)}
|
||||
>
|
||||
{stats ? 'Refresh stats' : 'Fetch stats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -809,19 +1213,50 @@ export default function Dashboard() {
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
|
||||
onClick={() => setModal('branch')}
|
||||
onClick={applyStagedEdits}
|
||||
disabled={applyLoading}
|
||||
>
|
||||
Save as branch
|
||||
{applyLoading ? 'Applying…' : 'Apply to branch'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={() => setModal('branch')}>
|
||||
Save as new branch
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
|
||||
Discard
|
||||
</button>
|
||||
{applyError && (
|
||||
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
|
||||
{applyError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadBranchError && (
|
||||
<div style={{
|
||||
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<span>{uploadBranchError}</span>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setUploadBranchError('')}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propagateError && (
|
||||
<div style={{
|
||||
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
|
||||
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<span>{propagateError}</span>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setPropagateError('')}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
||||
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
|
||||
{(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActiveTab(t)}
|
||||
@@ -840,7 +1275,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* tab content */}
|
||||
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
|
||||
<div style={{ padding: activeTab === 'preview' ? 0 : '16px 20px', flex: 1, overflow: activeTab === 'preview' ? 'hidden' : 'auto' }}>
|
||||
{activeTab === 'content' && (
|
||||
<ContentTab
|
||||
blocks={selectedVersion.structured_blocks ?? []}
|
||||
@@ -848,18 +1283,39 @@ export default function Dashboard() {
|
||||
onEdit={stageEdit}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'patches' && (
|
||||
<DiffViewer patches={selectedVersion.patches} />
|
||||
)}
|
||||
{activeTab === 'patches' && (() => {
|
||||
const inherited = selectedDoc ? computeInheritedPatches(selectedDoc.versions, selectedVersion.id) : [];
|
||||
const parentBranch = selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name;
|
||||
return (
|
||||
<DiffViewer
|
||||
patches={selectedVersion.patches}
|
||||
inheritedPatches={inherited}
|
||||
ancestorLabel={parentBranch}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{activeTab === 'submissions' && (
|
||||
<SubmissionsTab
|
||||
submissions={submissions}
|
||||
submissions={IS_DEMO
|
||||
? DEMO_SUBMISSIONS.filter(s => {
|
||||
const doc = DEMO_DOCUMENTS.find(d => d.id === selectedDocId);
|
||||
return doc?.versions.some(v => v.id === s.version_id);
|
||||
})
|
||||
: submissions}
|
||||
loading={subsLoading}
|
||||
versionId={selectedVersionId!}
|
||||
onNewSubmission={() => setModal('submission')}
|
||||
onRefresh={refreshSubs}
|
||||
onNewSubmission={() => !IS_DEMO && setModal('submission')}
|
||||
onRefresh={() => !IS_DEMO && refreshSubs()}
|
||||
onStatusChange={handleSubmissionStatusChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'insights' && (
|
||||
<InsightsPanel data={insights} />
|
||||
)}
|
||||
{activeTab === 'preview' && selectedDoc && (
|
||||
IS_DEMO
|
||||
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>Preview not available in demo mode.</div>
|
||||
: <PDFPreview documentId={selectedDoc.id} versionId={selectedVersion.id} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -885,7 +1341,12 @@ export default function Dashboard() {
|
||||
<PublishModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setModal(null)}
|
||||
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
|
||||
onDone={asset => {
|
||||
setModal(null);
|
||||
setRecentlyPublishedSlug(asset.slug);
|
||||
setPublishedAnalytics({});
|
||||
refreshDocs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 {
|
||||
const parsed = new URL(url);
|
||||
return parsed.origin.replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; }
|
||||
}
|
||||
|
||||
function authentikUrl() {
|
||||
@@ -18,37 +19,45 @@ function authentikUrl() {
|
||||
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;
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: `${base}/api/auth/callback`,
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
return `${baseHost}/application/o/authorize/?${params}`;
|
||||
return `${baseHost}/application/o/authorize/?${new URLSearchParams({
|
||||
response_type: 'code', client_id: clientId,
|
||||
redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile',
|
||||
})}`;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
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) return;
|
||||
if (!username || !password || loading) return;
|
||||
setLoading(true); setError('');
|
||||
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');
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -59,17 +68,11 @@ export default function LoginPage() {
|
||||
justifyContent: 'center', background: 'var(--bg)',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
|
||||
{/* brand */}
|
||||
<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 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>
|
||||
|
||||
{/* form card */}
|
||||
<div style={{
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, padding: '24px 24px 20px',
|
||||
@@ -82,7 +85,7 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="text" autoComplete="username" autoFocus
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
placeholder="admin" disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -92,7 +95,7 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="password" autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
placeholder="••••••••" disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -142,3 +145,11 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,60 @@
|
||||
'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;
|
||||
}
|
||||
|
||||
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, colorIndex = 0 }: {
|
||||
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 isLeaf = node.children.length === 0;
|
||||
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 style={{ position: 'relative' }}>
|
||||
@@ -58,7 +83,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
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: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none',
|
||||
opacity: hasChildren ? 1 : 0, pointerEvents: hasChildren ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
||||
@@ -84,6 +109,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
{v.version_label || v.branch_name}
|
||||
</span>
|
||||
|
||||
{/* own patch count */}
|
||||
{v.patches.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: 10, color: 'var(--text-faint)',
|
||||
@@ -94,6 +120,53 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
</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); }}
|
||||
@@ -111,7 +184,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && node.children.length > 0 && (
|
||||
{open && hasChildren && (
|
||||
<div style={{
|
||||
marginLeft: depth > 0 ? 22 : 14,
|
||||
borderLeft: `1px solid var(--border)`,
|
||||
@@ -125,6 +198,8 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onCopyMarkdown={onCopyMarkdown}
|
||||
onPropagate={onPropagate}
|
||||
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
||||
/>
|
||||
))}
|
||||
@@ -134,17 +209,18 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
|
||||
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} onDelete={onDeleteVersion} />
|
||||
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} onPropagate={onPropagate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
134
apps/webapp/src/components/cv/InsightsPanel.tsx
Normal file
134
apps/webapp/src/components/cv/InsightsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal file
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const API = "";
|
||||
export const IS_DEMO = process.env.NEXT_PUBLIC_DEMO === 'true';
|
||||
|
||||
export type StructuredBlock = {
|
||||
path: string;
|
||||
@@ -25,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;
|
||||
};
|
||||
@@ -50,6 +52,13 @@ export type Suggestion = {
|
||||
metadata_json?: { keywords?: string[]; confidence?: number } | null;
|
||||
};
|
||||
|
||||
export type SubmissionStatus =
|
||||
| 'draft'
|
||||
| 'tailoring'
|
||||
| 'pending_review'
|
||||
| 'published'
|
||||
| 'archived';
|
||||
|
||||
export type Submission = {
|
||||
id: string;
|
||||
version_id: string;
|
||||
@@ -57,7 +66,7 @@ export type Submission = {
|
||||
role_title: string;
|
||||
job_url?: string | null;
|
||||
job_description?: string | null;
|
||||
status: string;
|
||||
status: SubmissionStatus;
|
||||
suggestions: Suggestion[];
|
||||
created_at: string;
|
||||
};
|
||||
@@ -79,6 +88,21 @@ export type PublicAssetAnalytics = {
|
||||
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 {};
|
||||
@@ -92,6 +116,11 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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}`);
|
||||
}
|
||||
@@ -115,6 +144,15 @@ export async function uploadDocument(title: string, description: string | null,
|
||||
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,
|
||||
@@ -128,6 +166,17 @@ export async function createBranch(
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -142,8 +191,10 @@ export async function createSubmission(
|
||||
});
|
||||
}
|
||||
|
||||
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
|
||||
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
|
||||
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}`);
|
||||
@@ -172,6 +223,17 @@ export async function updateSuggestion(
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -201,6 +263,9 @@ export async function deleteDocument(documentId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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('.');
|
||||
@@ -13,12 +14,17 @@ async function verifySession(token: string): Promise<boolean> {
|
||||
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
|
||||
);
|
||||
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
|
||||
return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
|
||||
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();
|
||||
|
||||
189
dlib/ai/insights.py
Normal file
189
dlib/ai/insights.py
Normal 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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,79 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
from .parser import _detect_block_type
|
||||
|
||||
_STYLES = getSampleStyleSheet()
|
||||
|
||||
_STYLE_MAP: dict[str, ParagraphStyle] = {
|
||||
"heading": ParagraphStyle(
|
||||
"CVHeading", parent=_STYLES["Normal"],
|
||||
fontSize=15, leading=20, spaceBefore=10, spaceAfter=4,
|
||||
textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold",
|
||||
),
|
||||
"meta": ParagraphStyle(
|
||||
"CVMeta", parent=_STYLES["Normal"],
|
||||
fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"),
|
||||
),
|
||||
"summary": ParagraphStyle(
|
||||
"CVSummary", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"),
|
||||
),
|
||||
"bullet": ParagraphStyle(
|
||||
"CVBullet", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=3, leftIndent=14,
|
||||
bulletIndent=0, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
"skills": ParagraphStyle(
|
||||
"CVSkills", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
"text": ParagraphStyle(
|
||||
"CVText", parent=_STYLES["Normal"],
|
||||
fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"),
|
||||
),
|
||||
}
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
|
||||
doc = Document(BytesIO(docx_bytes))
|
||||
buf = BytesIO()
|
||||
pdf = SimpleDocTemplate(
|
||||
buf, pagesize=A4,
|
||||
leftMargin=2.2 * cm, rightMargin=2.2 * cm,
|
||||
topMargin=2 * cm, bottomMargin=2 * cm,
|
||||
)
|
||||
story: list = []
|
||||
prev_type: str | None = None
|
||||
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
if prev_type and prev_type != "empty":
|
||||
story.append(Spacer(1, 4))
|
||||
prev_type = "empty"
|
||||
continue
|
||||
|
||||
block_type = _detect_block_type(getattr(para.style, "name", None), para)
|
||||
style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"])
|
||||
|
||||
# draw separator line before new heading sections (except first)
|
||||
if block_type == "heading" and prev_type not in (None, "heading"):
|
||||
story.append(Spacer(1, 6))
|
||||
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
|
||||
|
||||
prefix = "\u2022\u00a0" if block_type == "bullet" else ""
|
||||
story.append(Paragraph(f"{prefix}{text}", style))
|
||||
prev_type = block_type
|
||||
|
||||
pdf.build(story)
|
||||
return buf.getvalue()
|
||||
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()
|
||||
|
||||
148
docker-compose.standalone.yml
Normal file
148
docker-compose.standalone.yml
Normal 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:
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
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}
|
||||
@@ -42,19 +43,20 @@ services:
|
||||
environment:
|
||||
- BACKEND_PORT=8080
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
|
||||
- MINIO_ENDPOINT=https://storage.cv.alves.world
|
||||
- MINIO_BUCKET=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- 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=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_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
|
||||
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
|
||||
depends_on:
|
||||
- postgres
|
||||
@@ -80,9 +82,9 @@ services:
|
||||
- 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=https://storage.cv.alves.world
|
||||
- MINIO_BUCKET=resume-branches
|
||||
- MINIO_REGION=us-east-1
|
||||
- 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
|
||||
@@ -127,7 +129,6 @@ services:
|
||||
command: server /data --console-address ":9001"
|
||||
networks:
|
||||
- cvfs-network
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
create-bucket:
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -11,9 +11,11 @@ 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
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
python-docx
|
||||
reportlab
|
||||
python-multipart
|
||||
boto3
|
||||
|
||||
|
||||
87
scripts/gen_demo_cv.py
Normal file
87
scripts/gen_demo_cv.py
Normal 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
632
uv.lock
generated
@@ -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"] },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user