diff --git a/.env.example b/.env.example
index 61ef19d..a0e8521 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,8 @@ COMPOSE_PROJECT_NAME=$NAME
# Backend
BACKEND_MODE=fastapi
BACKEND_PORT=9812
+DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
+CORS_ORIGINS=http://localhost:3000
# Ports
REDIS_PORT=6378
@@ -36,11 +38,14 @@ LOGDIR="/tmp/logs-$NAME/"
NEXT_PUBLIC_REQUIRE_AUTH=false
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_supabase_anon_key_here
+NEXT_PUBLIC_API_BASE_URL=http://localhost:9812
-# MinIO
+# MinIO Object Storage (used instead of S3)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
-MINIO_ENDPOINT=localhost:9900
+MINIO_ENDPOINT=http://localhost:9900
+MINIO_BUCKET=resume-branches
+MINIO_REGION=us-east-1
# ML
ML_LATEST_WEIGHTS_PATH=/app/models/weights
@@ -48,6 +53,12 @@ MLFLOW_TRACKING_URI=http://localhost:5000
# AI / Agents
ANTHROPIC_API_KEY=sk-ant-...
+# Auth / Publishing
+PUBLIC_BASE_URL=https://cv.alves.world
+CV_PUBLIC_DOMAIN=cv.alves.world
+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
diff --git a/README.md b/README.md
index d74edac..865ef42 100644
--- a/README.md
+++ b/README.md
@@ -153,3 +153,14 @@ bun x nx run ml:train
## 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.
diff --git a/apps/backend/fastapi/app/__init__.py b/apps/backend/fastapi/app/__init__.py
new file mode 100644
index 0000000..4c60f82
--- /dev/null
+++ b/apps/backend/fastapi/app/__init__.py
@@ -0,0 +1 @@
+"""FastAPI application for Resume Branches control plane."""
diff --git a/apps/backend/fastapi/app/api/deps.py b/apps/backend/fastapi/app/api/deps.py
new file mode 100644
index 0000000..dae1a63
--- /dev/null
+++ b/apps/backend/fastapi/app/api/deps.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from functools import lru_cache
+
+from fastapi import Depends, HTTPException, Request, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import get_settings
+from app.db.session import AsyncSessionLocal
+from dlib.auth import AuthenticatedUser, build_validator
+
+
+security_scheme = HTTPBearer(auto_error=False)
+
+
+async def get_db() -> AsyncSession:
+ async with AsyncSessionLocal() as session:
+ yield session
+
+
+@lru_cache(maxsize=1)
+def _validator():
+ settings = get_settings()
+ return build_validator(
+ issuer=settings.auth_oidc_issuer,
+ audience=settings.auth_oidc_audience,
+ disable=settings.auth_disable_verification,
+ )
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
+) -> AuthenticatedUser:
+ validator = _validator()
+ token = credentials.credentials if credentials else ""
+ try:
+ return await validator.validate(token)
+ except Exception as exc:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
+ ) from exc
diff --git a/apps/backend/fastapi/app/api/router.py b/apps/backend/fastapi/app/api/router.py
new file mode 100644
index 0000000..dccc0ca
--- /dev/null
+++ b/apps/backend/fastapi/app/api/router.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from app.api.routes import documents, 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)
diff --git a/apps/backend/fastapi/app/api/routes/__init__.py b/apps/backend/fastapi/app/api/routes/__init__.py
new file mode 100644
index 0000000..3a9020d
--- /dev/null
+++ b/apps/backend/fastapi/app/api/routes/__init__.py
@@ -0,0 +1,3 @@
+from . import documents, versions, submissions, public
+
+__all__ = ["documents", "versions", "submissions", "public"]
diff --git a/apps/backend/fastapi/app/api/routes/documents.py b/apps/backend/fastapi/app/api/routes/documents.py
new file mode 100644
index 0000000..5d1ec7e
--- /dev/null
+++ b/apps/backend/fastapi/app/api/routes/documents.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_user, get_db
+from app.schemas import DocumentListResponse, DocumentResponse
+from app.services.documents import create_document, get_document, list_documents
+from dlib.auth import AuthenticatedUser
+
+
+router = APIRouter(prefix="/documents", tags=["documents"])
+
+
+@router.get("/", response_model=DocumentListResponse)
+async def list_user_documents(
+ session: AsyncSession = Depends(get_db),
+ user: AuthenticatedUser = Depends(get_current_user),
+):
+ documents = await list_documents(session, owner_id=user.sub)
+ payload = [DocumentResponse.model_validate(doc) for doc in documents]
+ return DocumentListResponse(items=payload)
+
+
+@router.get("/{document_id}", response_model=DocumentResponse)
+async def get_user_document(
+ document_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")
+ return DocumentResponse.model_validate(document)
+
+
+@router.post("/", response_model=DocumentResponse)
+async def upload_document(
+ title: str = Form(...),
+ description: str | None = Form(default=None),
+ file: UploadFile = File(...),
+ session: AsyncSession = Depends(get_db),
+ user: AuthenticatedUser = Depends(get_current_user),
+):
+ document = await create_document(
+ session,
+ owner_id=user.sub,
+ title=title,
+ description=description,
+ upload=file,
+ )
+ return DocumentResponse.model_validate(document)
diff --git a/apps/backend/fastapi/app/api/routes/public.py b/apps/backend/fastapi/app/api/routes/public.py
new file mode 100644
index 0000000..687127c
--- /dev/null
+++ b/apps/backend/fastapi/app/api/routes/public.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_user, get_db
+from app.core.config import get_settings
+from app.models import PublicAsset
+from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
+from app.services.publication import publish_version
+from dlib.auth import AuthenticatedUser
+
+
+router = APIRouter(prefix="/public", tags=["public"])
+
+
+@router.post("/publish", response_model=PublicAssetResponse)
+async def publish(
+ payload: PublishRequest,
+ session: AsyncSession = Depends(get_db),
+ user: AuthenticatedUser = Depends(get_current_user),
+):
+ asset = await publish_version(
+ session,
+ owner_id=user.sub,
+ version_id=payload.version_id,
+ submission_id=payload.submission_id,
+ slug=payload.slug,
+ )
+ if not asset:
+ raise HTTPException(status_code=404, detail="Version or submission not found")
+ return _response_from_asset(asset)
+
+
+@router.get("/{slug}", response_model=PublicAssetLookupResponse)
+async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
+ stmt = select(PublicAsset).where(
+ PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
+ )
+ result = await session.execute(stmt)
+ asset = result.scalars().one_or_none()
+ if not asset:
+ raise HTTPException(status_code=404, detail="Asset not found")
+ return PublicAssetLookupResponse(asset=_response_from_asset(asset))
+
+
+def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
+ settings = get_settings()
+ url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
+ return PublicAssetResponse(
+ id=asset.id,
+ slug=asset.slug,
+ artifact_key=asset.artifact_key,
+ is_public=asset.is_public,
+ created_at=asset.created_at,
+ version_id=asset.version_id,
+ submission_id=asset.submission_id,
+ url=url,
+ )
diff --git a/apps/backend/fastapi/app/api/routes/submissions.py b/apps/backend/fastapi/app/api/routes/submissions.py
new file mode 100644
index 0000000..e0f5db5
--- /dev/null
+++ b/apps/backend/fastapi/app/api/routes/submissions.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+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 (
+ AiSuggestionRequest,
+ SubmissionCreateRequest,
+ SubmissionResponse,
+ SuggestionResponse,
+)
+from app.services.submissions import create_submission, request_ai_suggestions
+from dlib.auth import AuthenticatedUser
+
+
+router = APIRouter(prefix="/submissions", tags=["submissions"])
+
+
+@router.post("/", response_model=SubmissionResponse)
+async def create_submission_endpoint(
+ payload: SubmissionCreateRequest,
+ session: AsyncSession = Depends(get_db),
+ user: AuthenticatedUser = Depends(get_current_user),
+):
+ submission = await create_submission(
+ session,
+ owner_id=user.sub,
+ version_id=payload.version_id,
+ company_name=payload.company_name,
+ role_title=payload.role_title,
+ job_url=payload.job_url,
+ job_description=payload.job_description,
+ )
+ if not submission:
+ raise HTTPException(status_code=404, detail="Version not found")
+ return SubmissionResponse.model_validate(submission)
+
+
+@router.post("/{submission_id}/ai", response_model=list[SuggestionResponse])
+async def request_submissions_ai(
+ submission_id: str,
+ payload: AiSuggestionRequest,
+ session: AsyncSession = Depends(get_db),
+ user: AuthenticatedUser = Depends(get_current_user),
+):
+ suggestions = await request_ai_suggestions(
+ session,
+ owner_id=user.sub,
+ submission_id=submission_id,
+ job_description=payload.job_description,
+ focus_keywords=payload.focus_keywords,
+ )
+ if suggestions is None:
+ raise HTTPException(status_code=404, detail="Submission not found")
+ return [SuggestionResponse.model_validate(item) for item in suggestions]
diff --git a/apps/backend/fastapi/app/api/routes/versions.py b/apps/backend/fastapi/app/api/routes/versions.py
new file mode 100644
index 0000000..824c9f6
--- /dev/null
+++ b/apps/backend/fastapi/app/api/routes/versions.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_user, get_db
+from app.schemas import BranchCreateRequest, VersionResponse
+from app.services.versions import create_branch
+from dlib.auth import AuthenticatedUser
+
+
+router = APIRouter(prefix="/versions", tags=["versions"])
+
+
+@router.post("/branches", response_model=VersionResponse)
+async def create_version_branch(
+ payload: BranchCreateRequest,
+ 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,
+ )
+ if not version:
+ raise HTTPException(status_code=404, detail="Parent version not found")
+ return VersionResponse.model_validate(version)
diff --git a/apps/backend/fastapi/app/core/config.py b/apps/backend/fastapi/app/core/config.py
new file mode 100644
index 0000000..879d0da
--- /dev/null
+++ b/apps/backend/fastapi/app/core/config.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import List
+
+from pydantic import Field, field_validator
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+ project_name: str = "Resume Branches API"
+ api_prefix: str = "/api/v1"
+ environment: str = Field(default="local", alias="ENVIRONMENT")
+ database_url: str = Field(
+ default="postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches",
+ alias="DATABASE_URL",
+ )
+ cors_origins: List[str] = Field(
+ default_factory=lambda: ["http://localhost:3000"], alias="CORS_ORIGINS"
+ )
+
+ storage_bucket: str = Field(default="resume-branches", alias="MINIO_BUCKET")
+ storage_region: str = Field(default="us-east-1", alias="MINIO_REGION")
+ storage_endpoint_url: str | None = Field(
+ default="http://localhost:9900", alias="MINIO_ENDPOINT"
+ )
+ storage_access_key: str | None = Field(default=None, alias="MINIO_ROOT_USER")
+ storage_secret_key: str | None = Field(default=None, alias="MINIO_ROOT_PASSWORD")
+ storage_path_prefix: str = Field(default="artifacts/cv")
+
+ auth_oidc_issuer: str | None = Field(default=None, alias="AUTH_OIDC_ISSUER")
+ auth_oidc_audience: str | None = Field(default=None, alias="AUTH_OIDC_AUDIENCE")
+ auth_disable_verification: bool = Field(
+ default=True, alias="AUTH_DISABLE_VERIFICATION"
+ )
+
+ celery_broker_url: str = Field(
+ default="redis://localhost:6379/0", alias="CELERY_BROKER_URL"
+ )
+ celery_result_backend: str | None = Field(
+ default=None, alias="CELERY_RESULT_BACKEND"
+ )
+
+ public_base_url: str = Field(
+ default="https://cv.alves.world", alias="PUBLIC_BASE_URL"
+ )
+ publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
+
+ class Config:
+ env_file = ".env"
+ extra = "ignore"
+
+ @field_validator("cors_origins", mode="before")
+ @classmethod
+ def _split_origins(cls, value):
+ if isinstance(value, str):
+ return [origin.strip() for origin in value.split(",") if origin.strip()]
+ return value
+
+
+@lru_cache(maxsize=1)
+def get_settings() -> Settings:
+ return Settings() # type: ignore[arg-type]
diff --git a/apps/backend/fastapi/app/db/base.py b/apps/backend/fastapi/app/db/base.py
new file mode 100644
index 0000000..09844eb
--- /dev/null
+++ b/apps/backend/fastapi/app/db/base.py
@@ -0,0 +1,8 @@
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+from app import models # noqa: E402,F401
diff --git a/apps/backend/fastapi/app/db/session.py b/apps/backend/fastapi/app/db/session.py
new file mode 100644
index 0000000..c96eef5
--- /dev/null
+++ b/apps/backend/fastapi/app/db/session.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from app.core.config import get_settings
+from app.db.base import Base
+
+
+settings = get_settings()
+engine = create_async_engine(
+ settings.database_url, echo=settings.environment == "local", future=True
+)
+AsyncSessionLocal = async_sessionmaker(
+ bind=engine, expire_on_commit=False, class_=AsyncSession
+)
+
+
+@asynccontextmanager
+async def lifespan(app): # pragma: no cover
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield
+
+
+async def get_session() -> AsyncSession:
+ async with AsyncSessionLocal() as session:
+ yield session
diff --git a/apps/backend/fastapi/app/main.py b/apps/backend/fastapi/app/main.py
new file mode 100644
index 0000000..8702479
--- /dev/null
+++ b/apps/backend/fastapi/app/main.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.api.router import api_router
+from app.core.config import get_settings
+from app.db.session import lifespan
+
+
+settings = get_settings()
+app = FastAPI(title=settings.project_name, lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ allow_credentials=True,
+)
+
+
+@app.get("/health")
+async def health():
+ return {"status": "ok"}
+
+
+app.include_router(api_router, prefix=settings.api_prefix)
diff --git a/apps/backend/fastapi/app/models/__init__.py b/apps/backend/fastapi/app/models/__init__.py
new file mode 100644
index 0000000..d71a8bc
--- /dev/null
+++ b/apps/backend/fastapi/app/models/__init__.py
@@ -0,0 +1,21 @@
+from .cv import (
+ AiSuggestion,
+ CvDocument,
+ CvPatch,
+ CvVersion,
+ PublicAsset,
+ Specialization,
+ Submission,
+ SubmissionStatus,
+)
+
+__all__ = [
+ "CvDocument",
+ "CvVersion",
+ "CvPatch",
+ "Specialization",
+ "Submission",
+ "SubmissionStatus",
+ "PublicAsset",
+ "AiSuggestion",
+]
diff --git a/apps/backend/fastapi/app/models/cv.py b/apps/backend/fastapi/app/models/cv.py
new file mode 100644
index 0000000..349b63d
--- /dev/null
+++ b/apps/backend/fastapi/app/models/cv.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import enum
+
+from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.base import Base
+from app.models.mixins import IdentifierMixin, TimestampMixin
+
+
+class CvDocument(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "cv_documents"
+
+ owner_id: Mapped[str] = mapped_column(String(255), index=True)
+ title: Mapped[str] = mapped_column(String(255))
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ root_version_id: Mapped[str | None] = mapped_column(
+ ForeignKey("cv_versions.id"), nullable=True
+ )
+
+ versions: Mapped[list["CvVersion"]] = relationship(
+ "CvVersion", back_populates="document"
+ )
+
+
+class CvVersion(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "cv_versions"
+
+ document_id: Mapped[str] = mapped_column(
+ ForeignKey("cv_documents.id", ondelete="CASCADE")
+ )
+ parent_version_id: Mapped[str | None] = mapped_column(
+ ForeignKey("cv_versions.id"), nullable=True
+ )
+ branch_name: Mapped[str] = mapped_column(String(120), default="root")
+ version_label: Mapped[str | None] = mapped_column(String(120), nullable=True)
+ artifact_docx_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ preview_html_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
+ metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+
+ document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
+ parent: Mapped["CvVersion" | None] = relationship(
+ "CvVersion", remote_side="CvVersion.id"
+ )
+ patches: Mapped[list["CvPatch"]] = relationship("CvPatch", back_populates="version")
+ submissions: Mapped[list["Submission"]] = relationship(
+ "Submission", back_populates="version"
+ )
+
+
+class CvPatch(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "cv_patches"
+
+ version_id: Mapped[str] = mapped_column(
+ ForeignKey("cv_versions.id", ondelete="CASCADE")
+ )
+ target_path: Mapped[str] = mapped_column(String(255))
+ operation: Mapped[str] = mapped_column(String(64))
+ old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
+ new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
+ metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+
+ version: Mapped[CvVersion] = relationship("CvVersion", back_populates="patches")
+
+
+class Specialization(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "specializations"
+
+ document_id: Mapped[str] = mapped_column(
+ ForeignKey("cv_documents.id", ondelete="CASCADE")
+ )
+ name: Mapped[str] = mapped_column(String(120))
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ based_on_version_id: Mapped[str | None] = mapped_column(
+ ForeignKey("cv_versions.id"), nullable=True
+ )
+ metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+
+
+class SubmissionStatus(str, enum.Enum):
+ draft = "draft"
+ tailoring = "tailoring"
+ pending_review = "pending_review"
+ published = "published"
+ archived = "archived"
+
+
+class Submission(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "submissions"
+
+ version_id: Mapped[str] = mapped_column(
+ ForeignKey("cv_versions.id", ondelete="CASCADE")
+ )
+ company_name: Mapped[str] = mapped_column(String(160))
+ role_title: Mapped[str] = mapped_column(String(160))
+ job_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ job_description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ status: Mapped[SubmissionStatus] = mapped_column(
+ Enum(SubmissionStatus), default=SubmissionStatus.draft
+ )
+ metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+
+ version: Mapped[CvVersion] = relationship("CvVersion", back_populates="submissions")
+ suggestions: Mapped[list["AiSuggestion"]] = relationship(
+ "AiSuggestion", back_populates="submission"
+ )
+ public_asset: Mapped["PublicAsset" | None] = relationship(
+ "PublicAsset", back_populates="submission"
+ )
+
+
+class PublicAsset(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "public_assets"
+
+ submission_id: Mapped[str | None] = mapped_column(
+ ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
+ )
+ version_id: Mapped[str | None] = mapped_column(
+ ForeignKey("cv_versions.id"), nullable=True
+ )
+ slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
+ artifact_key: Mapped[str] = mapped_column(String(512))
+ is_public: Mapped[bool] = mapped_column(Boolean, default=True)
+ expires_at: Mapped[str | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+
+ submission: Mapped[Submission | None] = relationship(
+ "Submission", back_populates="public_asset"
+ )
+ version: Mapped[CvVersion | None] = relationship("CvVersion")
+
+
+class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
+ __tablename__ = "ai_suggestions"
+
+ submission_id: Mapped[str] = mapped_column(
+ ForeignKey("submissions.id", ondelete="CASCADE")
+ )
+ target_path: Mapped[str] = mapped_column(String(255))
+ operation: Mapped[str] = mapped_column(String(64))
+ proposed_text: Mapped[str | None] = mapped_column(Text, nullable=True)
+ rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
+ accepted: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
+ metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
+
+ submission: Mapped[Submission] = relationship(
+ "Submission", back_populates="suggestions"
+ )
diff --git a/apps/backend/fastapi/app/models/mixins.py b/apps/backend/fastapi/app/models/mixins.py
new file mode 100644
index 0000000..ba60304
--- /dev/null
+++ b/apps/backend/fastapi/app/models/mixins.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import uuid4
+
+from sqlalchemy import DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class IdentifierMixin:
+ id: Mapped[str] = mapped_column(
+ UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())
+ )
+
+
+class TimestampMixin:
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=datetime.utcnow
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
+ )
diff --git a/apps/backend/fastapi/app/schemas/__init__.py b/apps/backend/fastapi/app/schemas/__init__.py
new file mode 100644
index 0000000..4a1082a
--- /dev/null
+++ b/apps/backend/fastapi/app/schemas/__init__.py
@@ -0,0 +1,29 @@
+from .cv import (
+ AiSuggestionRequest,
+ BranchCreateRequest,
+ DocumentCreateResult,
+ DocumentListResponse,
+ DocumentResponse,
+ PublicAssetLookupResponse,
+ PublicAssetResponse,
+ PublishRequest,
+ SubmissionCreateRequest,
+ SubmissionResponse,
+ SuggestionResponse,
+ VersionResponse,
+)
+
+__all__ = [
+ "DocumentResponse",
+ "DocumentListResponse",
+ "DocumentCreateResult",
+ "VersionResponse",
+ "BranchCreateRequest",
+ "SubmissionCreateRequest",
+ "SubmissionResponse",
+ "AiSuggestionRequest",
+ "SuggestionResponse",
+ "PublishRequest",
+ "PublicAssetResponse",
+ "PublicAssetLookupResponse",
+]
diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py
new file mode 100644
index 0000000..b2775d2
--- /dev/null
+++ b/apps/backend/fastapi/app/schemas/cv.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from dlib.cv.schema import PatchSuggestion, StructuredBlock
+
+
+class DocumentResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ title: str
+ description: str | None
+ owner_id: str
+ root_version_id: str | None
+ created_at: datetime
+ updated_at: datetime
+ versions: list["VersionResponse"] = Field(default_factory=list)
+
+
+class VersionResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ branch_name: str
+ version_label: str | None
+ parent_version_id: str | None
+ structured_blocks: list[StructuredBlock] | None = None
+ artifact_docx_key: str | None = None
+ preview_html_key: str | None = None
+ created_at: datetime
+ updated_at: datetime
+ patches: list["PatchResponse"] = Field(default_factory=list)
+
+
+class PatchResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ target_path: str
+ operation: str
+ old_value: str | None = None
+ new_value: str | None = None
+ metadata_json: dict[str, Any] | None = None
+ created_at: datetime
+
+
+class DocumentListResponse(BaseModel):
+ items: list[DocumentResponse]
+
+
+class DocumentCreateResult(BaseModel):
+ document: DocumentResponse
+
+
+class BranchCreateRequest(BaseModel):
+ parent_version_id: str
+ branch_name: str
+ version_label: str | None = None
+ patches: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class SubmissionResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ version_id: str
+ company_name: str
+ role_title: str
+ job_url: str | None = None
+ job_description: str | None = None
+ status: str
+ created_at: datetime
+ suggestions: list["SuggestionResponse"] = Field(default_factory=list)
+
+
+class SubmissionCreateRequest(BaseModel):
+ version_id: str
+ company_name: str
+ role_title: str
+ job_url: str | None = None
+ job_description: str | None = None
+
+
+class SuggestionResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ target_path: str
+ operation: str
+ proposed_text: str | None
+ rationale: str | None
+ accepted: bool | None
+ metadata_json: dict[str, Any] | None = None
+
+
+class AiSuggestionRequest(BaseModel):
+ job_description: str
+ focus_keywords: list[str] = Field(default_factory=list)
+
+
+class PublishRequest(BaseModel):
+ version_id: str | None = None
+ submission_id: str | None = None
+ slug: str | None = None
+
+
+class PublicAssetResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ slug: str
+ artifact_key: str
+ is_public: bool
+ created_at: datetime
+ version_id: str | None = None
+ submission_id: str | None = None
+ url: str | None = None
+
+
+class PublicAssetLookupResponse(BaseModel):
+ asset: PublicAssetResponse
diff --git a/apps/backend/fastapi/app/services/documents.py b/apps/backend/fastapi/app/services/documents.py
new file mode 100644
index 0000000..d05b91d
--- /dev/null
+++ b/apps/backend/fastapi/app/services/documents.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from fastapi import UploadFile
+from sqlalchemy import 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
+
+
+async def create_document(
+ session: AsyncSession,
+ *,
+ owner_id: str,
+ title: str,
+ description: str | None,
+ upload: UploadFile,
+) -> CvDocument:
+ artifact_key, file_bytes = await persist_upload(upload, owner_id)
+ structured = parse_docx_bytes(file_bytes, version_label="root")
+
+ doc = CvDocument(owner_id=owner_id, title=title, description=description)
+ version = CvVersion(
+ document=doc,
+ 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(doc)
+ await session.commit()
+ await session.refresh(doc, attribute_names=["versions"])
+ return doc
+
+
+async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
+ stmt = (
+ select(CvDocument)
+ .where(CvDocument.owner_id == owner_id)
+ .options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
+ .order_by(CvDocument.created_at.desc())
+ )
+ result = await session.execute(stmt)
+ return result.scalars().unique().all()
+
+
+async def get_document(
+ session: AsyncSession, owner_id: str, document_id: str
+) -> CvDocument | None:
+ stmt = (
+ select(CvDocument)
+ .where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
+ .options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
+ )
+ result = await session.execute(stmt)
+ return result.scalars().unique().one_or_none()
diff --git a/apps/backend/fastapi/app/services/publication.py b/apps/backend/fastapi/app/services/publication.py
new file mode 100644
index 0000000..3233126
--- /dev/null
+++ b/apps/backend/fastapi/app/services/publication.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import re
+from datetime import datetime
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models import CvDocument, CvVersion, PublicAsset, Submission
+
+
+async def publish_version(
+ session: AsyncSession,
+ *,
+ owner_id: str,
+ version_id: str | None,
+ submission_id: str | None,
+ slug: str | None,
+) -> PublicAsset | None:
+ target_version: CvVersion | None = None
+ target_submission: Submission | None = None
+
+ if submission_id:
+ stmt = (
+ select(Submission)
+ .join(CvVersion)
+ .join(CvDocument)
+ .where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
+ )
+ result = await session.execute(stmt)
+ target_submission = result.scalars().one_or_none()
+ target_version = target_submission.version if target_submission else None
+ elif version_id:
+ stmt = (
+ select(CvVersion)
+ .join(CvDocument)
+ .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
+ )
+ result = await session.execute(stmt)
+ target_version = result.scalars().one_or_none()
+ else:
+ return None
+
+ if not target_version or not target_version.artifact_docx_key:
+ return None
+
+ resolved_slug = slugify(slug or target_version.version_label or "cv")
+ if not resolved_slug:
+ resolved_slug = f"cv-{uuid4().hex[:6]}"
+
+ asset = PublicAsset(
+ submission_id=target_submission.id if target_submission else None,
+ version_id=target_version.id,
+ slug=resolved_slug,
+ artifact_key=target_version.artifact_docx_key,
+ is_public=True,
+ expires_at=None,
+ )
+ session.add(asset)
+ await session.commit()
+ await session.refresh(asset)
+ return asset
+
+
+def slugify(value: str) -> str:
+ safe = re.sub(r"[^a-zA-Z0-9-]+", "-", value.strip().lower())
+ safe = re.sub(r"-+", "-", safe).strip("-")
+ return safe[:120]
diff --git a/apps/backend/fastapi/app/services/storage.py b/apps/backend/fastapi/app/services/storage.py
new file mode 100644
index 0000000..cd5df5f
--- /dev/null
+++ b/apps/backend/fastapi/app/services/storage.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+from typing import Tuple
+
+from fastapi import UploadFile
+
+from app.core.config import get_settings
+from dlib.storage import MinioStorageClient, MinioStorageConfig
+
+
+def _build_storage_client() -> MinioStorageClient:
+ settings = get_settings()
+ config = MinioStorageConfig(
+ bucket_name=settings.storage_bucket,
+ region_name=settings.storage_region,
+ endpoint_url=settings.storage_endpoint_url,
+ access_key_id=settings.storage_access_key,
+ secret_access_key=settings.storage_secret_key,
+ path_prefix=settings.storage_path_prefix,
+ )
+ return MinioStorageClient(config)
+
+
+storage_client = _build_storage_client()
+
+
+def build_artifact_key(owner_id: str, suffix: str) -> str:
+ safe_suffix = suffix.lstrip(".") if suffix else ""
+ timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
+ return storage_client.build_key(
+ stem=f"{owner_id}-{timestamp}", extension=safe_suffix
+ )
+
+
+async def persist_upload(upload: UploadFile, owner_id: str) -> Tuple[str, bytes]:
+ data = await upload.read()
+ suffix = Path(upload.filename or "document.docx").suffix or ".docx"
+ key = build_artifact_key(owner_id, suffix)
+ storage_client.upload_bytes(key=key, data=data, content_type=upload.content_type)
+ return key, data
+
+
+def build_public_url(key: str) -> str | None:
+ settings = get_settings()
+ if settings.storage_endpoint_url:
+ # Object storage accessible through endpoint URL
+ base = settings.storage_endpoint_url.rstrip("/")
+ return f"{base}/{settings.storage_bucket}/{key}"
+ return storage_client.generate_presigned_url(key=key, expires_in=3600)
diff --git a/apps/backend/fastapi/app/services/submissions.py b/apps/backend/fastapi/app/services/submissions.py
new file mode 100644
index 0000000..a4fe415
--- /dev/null
+++ b/apps/backend/fastapi/app/services/submissions.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from dlib.ai.tailoring import TailoringContext, generate_tailoring_suggestions
+from dlib.cv import StructuredBlock, StructuredDocument
+
+from app.models import AiSuggestion, CvDocument, CvVersion, Submission, SubmissionStatus
+
+
+async def create_submission(
+ session: AsyncSession,
+ *,
+ owner_id: str,
+ version_id: str,
+ company_name: str,
+ role_title: str,
+ job_url: str | None,
+ job_description: str | None,
+) -> Submission | None:
+ version = await _get_version_for_owner(session, owner_id, version_id)
+ if not version:
+ return None
+ submission = Submission(
+ version_id=version.id,
+ company_name=company_name,
+ role_title=role_title,
+ job_url=job_url,
+ job_description=job_description,
+ status=SubmissionStatus.draft,
+ )
+ session.add(submission)
+ await session.commit()
+ await session.refresh(submission)
+ return submission
+
+
+async def request_ai_suggestions(
+ session: AsyncSession,
+ *,
+ owner_id: str,
+ submission_id: str,
+ job_description: str,
+ focus_keywords: list[str],
+) -> list[AiSuggestion] | None:
+ submission = await _get_submission_for_owner(session, owner_id, submission_id)
+ if not submission:
+ return None
+ version = submission.version
+ document = StructuredDocument(
+ version_label=version.version_label,
+ blocks=[
+ StructuredBlock.model_validate(block)
+ for block in version.structured_blocks or []
+ ],
+ )
+ context = TailoringContext(
+ job_description=job_description, focus_keywords=focus_keywords
+ )
+ suggestions = generate_tailoring_suggestions(context, document)
+ if not suggestions:
+ return []
+ submission.status = SubmissionStatus.tailoring
+ created: list[AiSuggestion] = []
+ for suggestion in suggestions:
+ ai_row = AiSuggestion(
+ submission_id=submission.id,
+ target_path=suggestion.target_path,
+ operation=suggestion.operation.value,
+ proposed_text=suggestion.new_value,
+ rationale=suggestion.rationale,
+ metadata_json={
+ "keywords": suggestion.keywords,
+ "confidence": suggestion.confidence,
+ },
+ )
+ session.add(ai_row)
+ created.append(ai_row)
+ await session.commit()
+ for row in created:
+ await session.refresh(row)
+ return created
+
+
+async def _get_version_for_owner(
+ session: AsyncSession, owner_id: str, version_id: str
+) -> CvVersion | None:
+ stmt = (
+ select(CvVersion)
+ .join(CvDocument)
+ .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
+ )
+ result = await session.execute(stmt)
+ return result.scalars().one_or_none()
+
+
+async def _get_submission_for_owner(
+ session: AsyncSession,
+ owner_id: str,
+ submission_id: str,
+) -> Submission | None:
+ stmt = (
+ select(Submission)
+ .join(CvVersion)
+ .join(CvDocument)
+ .where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
+ .options(selectinload(Submission.version))
+ )
+ result = await session.execute(stmt)
+ return result.scalars().one_or_none()
diff --git a/apps/backend/fastapi/app/services/versions.py b/apps/backend/fastapi/app/services/versions.py
new file mode 100644
index 0000000..28f9d0c
--- /dev/null
+++ b/apps/backend/fastapi/app/services/versions.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from dlib.cv import (
+ StructuredBlock,
+ StructuredDocument,
+ PatchPayload,
+ apply_patchset,
+ validate_patchset,
+)
+
+from app.models import CvDocument, CvPatch, CvVersion
+
+
+async def create_branch(
+ session: AsyncSession,
+ *,
+ owner_id: str,
+ parent_version_id: str,
+ branch_name: str,
+ version_label: str | None,
+ patches: list[dict],
+) -> CvVersion | None:
+ stmt = (
+ select(CvVersion)
+ .join(CvDocument)
+ .where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
+ .options(selectinload(CvVersion.patches))
+ )
+ result = await session.execute(stmt)
+ parent = result.scalars().one_or_none()
+ if not parent:
+ return None
+
+ base_doc = StructuredDocument(
+ version_label=parent.version_label,
+ blocks=[
+ StructuredBlock.model_validate(block)
+ for block in parent.structured_blocks or []
+ ],
+ )
+ patch_models = [PatchPayload.model_validate(item) for item in patches]
+ if patch_models:
+ validate_patchset(base_doc, patch_models)
+ updated_doc = apply_patchset(base_doc, patch_models)
+ else:
+ updated_doc = base_doc
+
+ new_version = CvVersion(
+ document_id=parent.document_id,
+ parent_version_id=parent.id,
+ branch_name=branch_name,
+ version_label=version_label or branch_name,
+ artifact_docx_key=parent.artifact_docx_key,
+ structured_blocks=[block.model_dump() for block in updated_doc.blocks],
+ metadata_json={"patch_count": len(patch_models)},
+ )
+
+ session.add(new_version)
+ await session.flush()
+ for patch in patch_models:
+ session.add(
+ CvPatch(
+ version_id=new_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()
+ await session.refresh(new_version)
+ return new_version
diff --git a/apps/backend/fastapi/server.py b/apps/backend/fastapi/server.py
index 931ef43..6dc7628 100644
--- a/apps/backend/fastapi/server.py
+++ b/apps/backend/fastapi/server.py
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
from dotenv import load_dotenv
+
load_dotenv()
app = FastAPI()
@@ -15,10 +16,12 @@ app.add_middleware(
allow_headers=["*"],
)
+
@app.get("/health")
async def health():
return {"status": "healthy"}
+
if __name__ == "__main__":
- PORT=int(os.getenv("BACKEND_PORT", 5000))
+ PORT = int(os.getenv("BACKEND_PORT", 5000))
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore
index c85ed52..c903e5f 100644
--- a/apps/webapp/.gitignore
+++ b/apps/webapp/.gitignore
@@ -12,5 +12,4 @@ yarn-error.log*
.env*
.vercel
*.tsbuildinfo
-next-env.d.ts
package-lock.json
diff --git a/apps/webapp/next-env.d.ts b/apps/webapp/next-env.d.ts
new file mode 100644
index 0000000..9bc3dd4
--- /dev/null
+++ b/apps/webapp/next-env.d.ts
@@ -0,0 +1,6 @@
+///
No resumes ingested yet
++ Upload your ATS-safe DOCX to create the canonical root. Each specialization will appear here as a branch with its own patch history. +
+{doc.description}
+ ) : null} +{version.branch_name}
+{version.version_label ?? "untitled"}
+No tailoring applied yet.
+ )} +{block.path}
+{block.text}
+ {keywords.length ? ( +