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 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index 238e52c..0d5c0c4 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -18,4 +18,4 @@ export default async function DashboardPage() { ) -} \ No newline at end of file +} diff --git a/apps/webapp/src/components/cv/DocumentTree.tsx b/apps/webapp/src/components/cv/DocumentTree.tsx new file mode 100644 index 0000000..17ef2c8 --- /dev/null +++ b/apps/webapp/src/components/cv/DocumentTree.tsx @@ -0,0 +1,103 @@ +import type { Document, Version, StructuredBlock } from "@/libs/api" + +type Props = { + documents: Document[] +} + +const gradientPalette = ["from-amber-200/60", "from-sky-200/50", "from-rose-200/50", "from-emerald-200/50"] + +export function DocumentTree({ documents }: Props) { + if (!documents.length) { + return ( +
+

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. +

+
+ ) + } + + return ( +
+ {documents.map((doc, docIndex) => ( +
+
+ Root CV +

{doc.title}

+ {doc.description ? ( +

{doc.description}

+ ) : null} +
+
+ {doc.versions.map((version) => ( +
+ +
+ ))} +
+
+ ))} +
+ ) +} + +function BranchCard({ version }: { version: Version }) { + const patches = version.patches ?? [] + const structured = version.structured_blocks ?? [] + const blockPreview = structured.slice(0, 2) + return ( +
+
+
+

{version.branch_name}

+

{version.version_label ?? "untitled"}

+
+ + {patches.length} patches + +
+ {blockPreview.length ? ( +
+ {blockPreview.map((block) => ( + + ))} +
+ ) : null} + {patches.length ? ( +
    + {patches.slice(-3).map((patch) => ( +
  • + {patch.target_path} + → {patch.operation} +
  • + ))} +
+ ) : ( +

No tailoring applied yet.

+ )} +
+ ) +} + +function KeywordChip({ block }: { block: StructuredBlock }) { + const keywords = block.keywords.slice(0, 3) + return ( +
+

{block.path}

+

{block.text}

+ {keywords.length ? ( +
+ {keywords.map((keyword) => ( + + {keyword} + + ))} +
+ ) : null} +
+ ) +} diff --git a/apps/webapp/src/components/cv/UploadResumeCard.tsx b/apps/webapp/src/components/cv/UploadResumeCard.tsx new file mode 100644 index 0000000..ba0d92d --- /dev/null +++ b/apps/webapp/src/components/cv/UploadResumeCard.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useState, ChangeEvent, FormEvent } from "react" + +import { API_BASE_URL } from "@/libs/api" + +export function UploadResumeCard() { + const router = useRouter() + const [file, setFile] = useState(null) + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle") + const [error, setError] = useState(null) + + function onFileChange(event: ChangeEvent) { + const nextFile = event.target.files?.[0] + if (nextFile) { + setFile(nextFile) + setTitle((prev: string) => (prev ? prev : nextFile.name.replace(/\.docx$/i, ""))) + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (!file || !title) { + setError("Title and DOCX are both required") + return + } + setStatus("uploading") + setError(null) + const formData = new FormData() + formData.append("title", title) + if (description) { + formData.append("description", description) + } + formData.append("file", file) + const response = await fetch(`${API_BASE_URL}/api/v1/documents`, { + method: "POST", + body: formData, + }) + if (!response.ok) { + setStatus("error") + setError("Upload failed. Ensure the FastAPI backend is reachable.") + return + } + setStatus("success") + setFile(null) + setDescription("") + router.refresh() + setTimeout(() => setStatus("idle"), 2500) + } + + return ( +
+
+
+

Canonical CV

+

Upload ATS-safe DOCX

+
+ {status === "uploading" ? ( +
+ Uploading… +
+ ) : null} +
+
+ +