mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
Finish MVP and dockerize
This commit is contained in:
15
.env.example
15
.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
|
||||
|
||||
11
README.md
11
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.
|
||||
|
||||
1
apps/backend/fastapi/app/__init__.py
Normal file
1
apps/backend/fastapi/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI application for Resume Branches control plane."""
|
||||
42
apps/backend/fastapi/app/api/deps.py
Normal file
42
apps/backend/fastapi/app/api/deps.py
Normal file
@@ -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
|
||||
11
apps/backend/fastapi/app/api/router.py
Normal file
11
apps/backend/fastapi/app/api/router.py
Normal file
@@ -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)
|
||||
3
apps/backend/fastapi/app/api/routes/__init__.py
Normal file
3
apps/backend/fastapi/app/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import documents, versions, submissions, public
|
||||
|
||||
__all__ = ["documents", "versions", "submissions", "public"]
|
||||
52
apps/backend/fastapi/app/api/routes/documents.py
Normal file
52
apps/backend/fastapi/app/api/routes/documents.py
Normal file
@@ -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)
|
||||
60
apps/backend/fastapi/app/api/routes/public.py
Normal file
60
apps/backend/fastapi/app/api/routes/public.py
Normal file
@@ -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,
|
||||
)
|
||||
56
apps/backend/fastapi/app/api/routes/submissions.py
Normal file
56
apps/backend/fastapi/app/api/routes/submissions.py
Normal file
@@ -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]
|
||||
31
apps/backend/fastapi/app/api/routes/versions.py
Normal file
31
apps/backend/fastapi/app/api/routes/versions.py
Normal file
@@ -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)
|
||||
63
apps/backend/fastapi/app/core/config.py
Normal file
63
apps/backend/fastapi/app/core/config.py
Normal file
@@ -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]
|
||||
8
apps/backend/fastapi/app/db/base.py
Normal file
8
apps/backend/fastapi/app/db/base.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
from app import models # noqa: E402,F401
|
||||
29
apps/backend/fastapi/app/db/session.py
Normal file
29
apps/backend/fastapi/app/db/session.py
Normal file
@@ -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
|
||||
28
apps/backend/fastapi/app/main.py
Normal file
28
apps/backend/fastapi/app/main.py
Normal file
@@ -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)
|
||||
21
apps/backend/fastapi/app/models/__init__.py
Normal file
21
apps/backend/fastapi/app/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from .cv import (
|
||||
AiSuggestion,
|
||||
CvDocument,
|
||||
CvPatch,
|
||||
CvVersion,
|
||||
PublicAsset,
|
||||
Specialization,
|
||||
Submission,
|
||||
SubmissionStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CvDocument",
|
||||
"CvVersion",
|
||||
"CvPatch",
|
||||
"Specialization",
|
||||
"Submission",
|
||||
"SubmissionStatus",
|
||||
"PublicAsset",
|
||||
"AiSuggestion",
|
||||
]
|
||||
152
apps/backend/fastapi/app/models/cv.py
Normal file
152
apps/backend/fastapi/app/models/cv.py
Normal file
@@ -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"
|
||||
)
|
||||
23
apps/backend/fastapi/app/models/mixins.py
Normal file
23
apps/backend/fastapi/app/models/mixins.py
Normal file
@@ -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
|
||||
)
|
||||
29
apps/backend/fastapi/app/schemas/__init__.py
Normal file
29
apps/backend/fastapi/app/schemas/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
125
apps/backend/fastapi/app/schemas/cv.py
Normal file
125
apps/backend/fastapi/app/schemas/cv.py
Normal file
@@ -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
|
||||
63
apps/backend/fastapi/app/services/documents.py
Normal file
63
apps/backend/fastapi/app/services/documents.py
Normal file
@@ -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()
|
||||
69
apps/backend/fastapi/app/services/publication.py
Normal file
69
apps/backend/fastapi/app/services/publication.py
Normal file
@@ -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]
|
||||
51
apps/backend/fastapi/app/services/storage.py
Normal file
51
apps/backend/fastapi/app/services/storage.py
Normal file
@@ -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)
|
||||
112
apps/backend/fastapi/app/services/submissions.py
Normal file
112
apps/backend/fastapi/app/services/submissions.py
Normal file
@@ -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()
|
||||
78
apps/backend/fastapi/app/services/versions.py
Normal file
78
apps/backend/fastapi/app/services/versions.py
Normal file
@@ -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
|
||||
@@ -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))
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||
|
||||
1
apps/webapp/.gitignore
vendored
1
apps/webapp/.gitignore
vendored
@@ -12,5 +12,4 @@ yarn-error.log*
|
||||
.env*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
package-lock.json
|
||||
|
||||
6
apps/webapp/next-env.d.ts
vendored
Normal file
6
apps/webapp/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
103
apps/webapp/src/components/cv/DocumentTree.tsx
Normal file
103
apps/webapp/src/components/cv/DocumentTree.tsx
Normal file
@@ -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 (
|
||||
<div className="rounded-3xl border border-white/10 bg-gradient-to-br from-slate-900/40 to-slate-800/60 p-10 text-white/80">
|
||||
<p className="text-lg font-semibold">No resumes ingested yet</p>
|
||||
<p className="mt-3 text-sm text-white/60">
|
||||
Upload your ATS-safe DOCX to create the canonical root. Each specialization will appear here as a branch with its own patch history.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{documents.map((doc, docIndex) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`rounded-3xl border border-white/10 bg-gradient-to-br ${gradientPalette[docIndex % gradientPalette.length]} to-slate-900/50 p-6 text-white`}
|
||||
>
|
||||
<div className="flex flex-col gap-1 border-b border-white/15 pb-4">
|
||||
<span className="text-sm uppercase tracking-[0.2em] text-white/60">Root CV</span>
|
||||
<h3 className="text-2xl font-semibold">{doc.title}</h3>
|
||||
{doc.description ? (
|
||||
<p className="text-white/70">{doc.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{doc.versions.map((version) => (
|
||||
<div key={version.id}>
|
||||
<BranchCard version={version} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchCard({ version }: { version: Version }) {
|
||||
const patches = version.patches ?? []
|
||||
const structured = version.structured_blocks ?? []
|
||||
const blockPreview = structured.slice(0, 2)
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 shadow-inner">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm text-white/60">{version.branch_name}</p>
|
||||
<p className="text-lg font-semibold">{version.version_label ?? "untitled"}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white/10 px-3 py-1 text-xs uppercase tracking-wide text-white/70">
|
||||
{patches.length} patches
|
||||
</span>
|
||||
</div>
|
||||
{blockPreview.length ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{blockPreview.map((block) => (
|
||||
<KeywordChip key={block.path} block={block} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{patches.length ? (
|
||||
<ul className="mt-4 divide-y divide-white/5">
|
||||
{patches.slice(-3).map((patch) => (
|
||||
<li key={patch.id} className="py-2 text-sm text-white/80">
|
||||
<span className="font-mono text-white/60">{patch.target_path}</span>
|
||||
<span className="ml-2 text-white">→ {patch.operation}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-white/60">No tailoring applied yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeywordChip({ block }: { block: StructuredBlock }) {
|
||||
const keywords = block.keywords.slice(0, 3)
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-white/60">{block.path}</p>
|
||||
<p className="text-sm text-white/90">{block.text}</p>
|
||||
{keywords.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-white/70">
|
||||
{keywords.map((keyword) => (
|
||||
<span key={keyword} className="rounded-full bg-white/10 px-2 py-0.5">
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
apps/webapp/src/components/cv/UploadResumeCard.tsx
Normal file
115
apps/webapp/src/components/cv/UploadResumeCard.tsx
Normal file
@@ -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<File | null>(null)
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function onFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-3xl border border-white/10 bg-slate-900/70 p-6 text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-white/60">Canonical CV</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Upload ATS-safe DOCX</h2>
|
||||
</div>
|
||||
{status === "uploading" ? (
|
||||
<div className="rounded-full border border-white/30 px-3 py-1 text-xs uppercase tracking-wide text-white/80">
|
||||
Uploading…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
<label className="block text-sm text-white/70">
|
||||
Title
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.target.value)}
|
||||
placeholder="Resume Branch name"
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-2 text-white outline-none focus:border-white/40"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-white/70">
|
||||
Description
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.target.value)}
|
||||
placeholder="Optional context for this CV"
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none focus:border-white/40"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-white/70">
|
||||
DOCX File
|
||||
<input
|
||||
type="file"
|
||||
accept=".docx"
|
||||
className="mt-2 w-full rounded-2xl border border-dashed border-white/25 bg-black/10 px-4 py-4 text-sm text-white/70"
|
||||
onChange={onFileChange}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
|
||||
{status === "success" ? (
|
||||
<p className="mt-4 text-sm text-emerald-300">Ingestion queued. Blocks appear in the tree within seconds.</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-6 w-full rounded-2xl bg-white/90 px-4 py-3 text-center text-slate-900 transition hover:bg-white"
|
||||
disabled={status === "uploading"}
|
||||
>
|
||||
Ingest Canonical CV
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
50
apps/webapp/src/libs/api.ts
Normal file
50
apps/webapp/src/libs/api.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9812";
|
||||
|
||||
export type StructuredBlock = {
|
||||
path: string
|
||||
block_type: string
|
||||
text: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type Patch = {
|
||||
id: string
|
||||
target_path: string
|
||||
operation: string
|
||||
rationale?: string | null
|
||||
new_value?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Version = {
|
||||
id: string
|
||||
branch_name: string
|
||||
version_label?: string | null
|
||||
parent_version_id?: string | null
|
||||
structured_blocks?: StructuredBlock[] | null
|
||||
patches?: Patch[]
|
||||
}
|
||||
|
||||
export type Document = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
owner_id: string
|
||||
versions: Version[]
|
||||
}
|
||||
|
||||
export async function fetchDocuments(): Promise<Document[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/documents`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load documents")
|
||||
}
|
||||
const payload = await response.json()
|
||||
return payload?.items ?? []
|
||||
}
|
||||
|
||||
export { API_BASE_URL }
|
||||
@@ -1,23 +1,68 @@
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from celery import Celery
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from dlib.ai import TailoringContext, generate_tailoring_suggestions
|
||||
from dlib.cv import StructuredBlock, StructuredDocument, parse_docx_bytes
|
||||
from dlib.storage import MinioStorageClient, MinioStorageConfig
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Redis connection
|
||||
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||
app = Celery('worker', broker=redis_url, backend=redis_url)
|
||||
|
||||
# Redis / Celery configuration
|
||||
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
app = Celery(
|
||||
"worker", broker=redis_url, backend=os.getenv("CELERY_RESULT_BACKEND", redis_url)
|
||||
)
|
||||
|
||||
|
||||
def _storage_client() -> MinioStorageClient:
|
||||
config = MinioStorageConfig(
|
||||
bucket_name=os.getenv("MINIO_BUCKET", "resume-branches"),
|
||||
region_name=os.getenv("MINIO_REGION", "us-east-1"),
|
||||
endpoint_url=os.getenv("MINIO_ENDPOINT", "http://localhost:9900"),
|
||||
access_key_id=os.getenv("MINIO_ROOT_USER"),
|
||||
secret_access_key=os.getenv("MINIO_ROOT_PASSWORD"),
|
||||
path_prefix=os.getenv("MINIO_PATH_PREFIX", "artifacts/cv"),
|
||||
)
|
||||
return MinioStorageClient(config)
|
||||
|
||||
|
||||
storage_client = _storage_client()
|
||||
|
||||
|
||||
@app.task
|
||||
def simple_task(message):
|
||||
"""A simple task that processes a message and returns a result"""
|
||||
time.sleep(2) # Simulate some work
|
||||
def simple_task(message: str) -> str:
|
||||
"""Basic smoke-test task."""
|
||||
time.sleep(1)
|
||||
return f"Processed: {message}"
|
||||
|
||||
@app.task
|
||||
def add_numbers(x, y):
|
||||
"""Simple math task"""
|
||||
return x + y
|
||||
|
||||
if __name__ == '__main__':
|
||||
@app.task
|
||||
def parse_document_from_storage(key: str) -> list[dict[str, Any]]:
|
||||
data = storage_client.download_bytes(key=key)
|
||||
structured = parse_docx_bytes(data)
|
||||
return [block.model_dump() for block in structured.blocks]
|
||||
|
||||
|
||||
@app.task
|
||||
def generate_tailoring(
|
||||
job_description: str, blocks: list[dict[str, Any]], focus_keywords: list[str]
|
||||
):
|
||||
context = TailoringContext(
|
||||
job_description=job_description, focus_keywords=focus_keywords
|
||||
)
|
||||
document = StructuredDocument(
|
||||
version_label="worker",
|
||||
blocks=[StructuredBlock.model_validate(block) for block in blocks],
|
||||
)
|
||||
suggestions = generate_tailoring_suggestions(context, document)
|
||||
return [suggestion.model_dump() for suggestion in suggestions]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.start()
|
||||
|
||||
5
dlib/__init__.py
Normal file
5
dlib/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Domain-specific helpers for Resume Branches."""
|
||||
|
||||
from . import cv, ai, storage, auth # noqa: F401
|
||||
|
||||
__all__ = ["cv", "ai", "storage", "auth"]
|
||||
3
dlib/ai/__init__.py
Normal file
3
dlib/ai/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .tailoring import generate_tailoring_suggestions, TailoringContext
|
||||
|
||||
__all__ = ["generate_tailoring_suggestions", "TailoringContext"]
|
||||
134
dlib/ai/tailoring.py
Normal file
134
dlib/ai/tailoring.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
from typing import Sequence
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from alveslib import ask
|
||||
|
||||
from dlib.cv.schema import (
|
||||
PatchOperation,
|
||||
PatchSuggestion,
|
||||
StructuredBlock,
|
||||
StructuredDocument,
|
||||
)
|
||||
|
||||
|
||||
class TailoringContext(BaseModel):
|
||||
job_description: str
|
||||
focus_keywords: list[str] = Field(default_factory=list)
|
||||
prohibited_terms: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def generate_tailoring_suggestions(
|
||||
context: TailoringContext,
|
||||
document: StructuredDocument,
|
||||
*,
|
||||
max_changes: int = 12,
|
||||
) -> list[PatchSuggestion]:
|
||||
if not document.blocks:
|
||||
return []
|
||||
if not os.getenv("ANTHROPIC_API_KEY"):
|
||||
return _rule_based_suggestions(context, document, max_changes)
|
||||
|
||||
prompt = _build_prompt(context, document, max_changes)
|
||||
raw = ask(prompt)
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
candidates = payload.get("patches", payload)
|
||||
except json.JSONDecodeError:
|
||||
return _rule_based_suggestions(context, document, max_changes)
|
||||
|
||||
suggestions: list[PatchSuggestion] = []
|
||||
for candidate in candidates[:max_changes]:
|
||||
try:
|
||||
suggestions.append(PatchSuggestion.model_validate(candidate))
|
||||
except Exception:
|
||||
continue
|
||||
return suggestions or _rule_based_suggestions(context, document, max_changes)
|
||||
|
||||
|
||||
def _rule_based_suggestions(
|
||||
context: TailoringContext,
|
||||
document: StructuredDocument,
|
||||
max_changes: int,
|
||||
) -> list[PatchSuggestion]:
|
||||
keywords = set([kw.lower() for kw in context.focus_keywords])
|
||||
if not keywords:
|
||||
keywords = set(_extract_keywords(context.job_description))
|
||||
suggestions: list[PatchSuggestion] = []
|
||||
for block in document.blocks:
|
||||
overlap = keywords.intersection({kw.lower() for kw in block.keywords})
|
||||
if not overlap and len(suggestions) < max_changes:
|
||||
keyword = next(iter(keywords), None)
|
||||
if keyword:
|
||||
suggestions.append(
|
||||
PatchSuggestion(
|
||||
target_path=block.path,
|
||||
operation=PatchOperation.BOOST_KEYWORD,
|
||||
new_value=keyword,
|
||||
rationale="Surface JD keyword in existing bullet",
|
||||
keywords=[keyword],
|
||||
confidence=0.4,
|
||||
)
|
||||
)
|
||||
elif overlap and len(suggestions) < max_changes:
|
||||
keyword = next(iter(overlap))
|
||||
suggestions.append(
|
||||
PatchSuggestion(
|
||||
target_path=block.path,
|
||||
operation=PatchOperation.REPLACE_TEXT,
|
||||
new_value=_strengthen_sentence(block, keyword),
|
||||
old_value=block.text,
|
||||
rationale=f"Highlight {keyword}",
|
||||
keywords=[keyword],
|
||||
confidence=0.55,
|
||||
)
|
||||
)
|
||||
return suggestions[:max_changes]
|
||||
|
||||
|
||||
def _strengthen_sentence(block: StructuredBlock, keyword: str) -> str:
|
||||
text = block.text.strip()
|
||||
if keyword.lower() not in text.lower():
|
||||
return f"{text} — emphasized {keyword} impact"
|
||||
return re.sub(keyword, keyword.upper(), text, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_keywords(job_description: str, limit: int = 8) -> list[str]:
|
||||
tokens = {}
|
||||
for token in re.findall(r"[A-Za-z][A-Za-z0-9+./-]{2,}", job_description):
|
||||
t = token.lower()
|
||||
tokens[t] = tokens.get(t, 0) + 1
|
||||
return [
|
||||
token
|
||||
for token, _ in sorted(tokens.items(), key=lambda kv: kv[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
context: TailoringContext, document: StructuredDocument, max_changes: int
|
||||
) -> str:
|
||||
lines = [f"{block.path}: {block.text}" for block in document.blocks]
|
||||
doc_preview = "\n".join(lines[:40])
|
||||
focus = ", ".join(context.focus_keywords) or "n/a"
|
||||
prohibited = ", ".join(context.prohibited_terms) or "n/a"
|
||||
return textwrap.dedent(
|
||||
f"""
|
||||
You are an ATS-preserving copy editor. Job description:\n{context.job_description}\n---\n
|
||||
Existing resume snippets:\n{doc_preview}
|
||||
|
||||
Provide at most {max_changes} JSON patch objects with fields
|
||||
target_path, operation, new_value, rationale, keywords, confidence.
|
||||
Allowed operations: replace_text, boost_keyword, suppress_block.
|
||||
Focus keywords: {focus}. Forbidden topics: {prohibited}.
|
||||
Ensure every change is truthful and preserves formatting.
|
||||
Respond with JSON: {{"patches": [{{...}}]}} only.
|
||||
"""
|
||||
).strip()
|
||||
3
dlib/auth/__init__.py
Normal file
3
dlib/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .oidc import AuthenticatedUser, OidcTokenValidator, build_validator
|
||||
|
||||
__all__ = ["AuthenticatedUser", "OidcTokenValidator", "build_validator"]
|
||||
92
dlib/auth/oidc.py
Normal file
92
dlib/auth/oidc.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthenticatedUser(BaseModel):
|
||||
sub: str
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
picture: str | None = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TokenValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OidcTokenValidator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
issuer: str | None,
|
||||
audience: str | None,
|
||||
jwks_url: str | None = None,
|
||||
disable: bool = False,
|
||||
) -> None:
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_url = jwks_url or (
|
||||
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
|
||||
)
|
||||
self.disable = disable or not issuer
|
||||
self._jwks: dict[str, Any] | None = None
|
||||
self._jwks_expiry: float = 0
|
||||
|
||||
async def validate(self, token: str) -> AuthenticatedUser:
|
||||
if self.disable or not token:
|
||||
return AuthenticatedUser(
|
||||
sub="dev-user", email="dev@example.com", name="Developer"
|
||||
)
|
||||
header = jwt.get_unverified_header(token)
|
||||
key = await self._get_key(header.get("kid"))
|
||||
if not key:
|
||||
raise TokenValidationError("Unable to resolve signing key")
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=[key.get("alg", "RS256")],
|
||||
audience=self.audience,
|
||||
issuer=self.issuer,
|
||||
)
|
||||
except JWTError as exc:
|
||||
raise TokenValidationError(str(exc)) from exc
|
||||
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
|
||||
if isinstance(roles, str):
|
||||
roles = [roles]
|
||||
return AuthenticatedUser(
|
||||
sub=str(claims.get("sub")),
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
picture=claims.get("picture"),
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
|
||||
if not self.jwks_url:
|
||||
return None
|
||||
if not self._jwks or time.time() > self._jwks_expiry:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(self.jwks_url)
|
||||
response.raise_for_status()
|
||||
self._jwks = response.json()
|
||||
self._jwks_expiry = time.time() + 3600
|
||||
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
|
||||
if kid:
|
||||
for key in keys:
|
||||
if key.get("kid") == kid:
|
||||
return key
|
||||
return keys[0] if keys else None
|
||||
|
||||
|
||||
def build_validator(
|
||||
*, issuer: str | None, audience: str | None, disable: bool
|
||||
) -> OidcTokenValidator:
|
||||
return OidcTokenValidator(issuer=issuer, audience=audience, disable=disable)
|
||||
22
dlib/cv/__init__.py
Normal file
22
dlib/cv/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from .schema import (
|
||||
StructuredBlock,
|
||||
StructuredDocument,
|
||||
PatchPayload,
|
||||
PatchSuggestion,
|
||||
PatchOperation,
|
||||
)
|
||||
from .parser import parse_docx_bytes, summarize_keywords
|
||||
from .patcher import apply_patchset
|
||||
from .ats_guard import validate_patchset
|
||||
|
||||
__all__ = [
|
||||
"StructuredBlock",
|
||||
"StructuredDocument",
|
||||
"PatchPayload",
|
||||
"PatchSuggestion",
|
||||
"PatchOperation",
|
||||
"parse_docx_bytes",
|
||||
"summarize_keywords",
|
||||
"apply_patchset",
|
||||
"validate_patchset",
|
||||
]
|
||||
44
dlib/cv/ats_guard.py
Normal file
44
dlib/cv/ats_guard.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from .schema import PatchPayload, PatchOperation, StructuredDocument
|
||||
|
||||
|
||||
class PatchValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
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)
|
||||
if not block:
|
||||
raise PatchValidationError(
|
||||
f"Target path {patch.target_path} does not exist in base document"
|
||||
)
|
||||
if patch.operation == PatchOperation.REPLACE_TEXT:
|
||||
if not patch.new_value:
|
||||
raise PatchValidationError("replace_text requires new_value")
|
||||
baseline = len(block.text.strip()) or 1
|
||||
if len(patch.new_value.strip()) / baseline > max_growth_ratio:
|
||||
raise PatchValidationError("Patch grows text beyond ATS safe threshold")
|
||||
if (
|
||||
patch.operation
|
||||
in {PatchOperation.REMOVE_BLOCK, PatchOperation.SUPPRESS_BLOCK}
|
||||
and block.block_type == "heading"
|
||||
):
|
||||
raise PatchValidationError(
|
||||
"Headings cannot be removed without manual confirmation"
|
||||
)
|
||||
104
dlib/cv/parser.py
Normal file
104
dlib/cv/parser.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
from typing import Iterable
|
||||
|
||||
from docx import Document
|
||||
|
||||
from .schema import StructuredBlock, StructuredDocument
|
||||
|
||||
|
||||
def _detect_block_type(style_name: str | None, paragraph) -> str:
|
||||
style = (style_name or "").lower()
|
||||
if style.startswith("heading"):
|
||||
return "heading"
|
||||
if (
|
||||
"bullet" in style
|
||||
or "list" in style
|
||||
or getattr(paragraph, "style", None)
|
||||
and getattr(paragraph.style, "name", "").lower().startswith("list")
|
||||
):
|
||||
return "bullet"
|
||||
return "text"
|
||||
|
||||
|
||||
def _build_path(block_type: str, counter: int, extra: str | None = None) -> str:
|
||||
suffix = f"{block_type}[{counter}]"
|
||||
if extra:
|
||||
return f"{suffix}.{extra}"
|
||||
return suffix
|
||||
|
||||
|
||||
def parse_docx_bytes(
|
||||
file_bytes: bytes, *, version_label: str | None = None
|
||||
) -> StructuredDocument:
|
||||
document = Document(BytesIO(file_bytes))
|
||||
counters: defaultdict[str, int] = defaultdict(int)
|
||||
blocks: list[StructuredBlock] = []
|
||||
|
||||
for paragraph in document.paragraphs:
|
||||
text = paragraph.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
block_type = _detect_block_type(
|
||||
getattr(paragraph.style, "name", None), paragraph
|
||||
)
|
||||
counters[block_type] += 1
|
||||
keywords = summarize_keywords([text])
|
||||
blocks.append(
|
||||
StructuredBlock(
|
||||
path=_build_path(block_type, counters[block_type]),
|
||||
block_type="heading"
|
||||
if block_type == "heading"
|
||||
else ("bullet" if block_type == "bullet" else "text"),
|
||||
text=text,
|
||||
keywords=keywords,
|
||||
metadata={
|
||||
"style": getattr(getattr(paragraph, "style", None), "name", "")
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for table_index, table in enumerate(document.tables):
|
||||
for row_index, row in enumerate(table.rows):
|
||||
for cell_index, cell in enumerate(row.cells):
|
||||
text = cell.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
counters["table"] += 1
|
||||
blocks.append(
|
||||
StructuredBlock(
|
||||
path=_build_path(
|
||||
"table",
|
||||
counters["table"],
|
||||
extra=f"{row_index}-{cell_index}",
|
||||
),
|
||||
block_type="table",
|
||||
text=text,
|
||||
keywords=summarize_keywords([text]),
|
||||
metadata={
|
||||
"table_index": table_index,
|
||||
"row": row_index,
|
||||
"cell": cell_index,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return StructuredDocument(version_label=version_label, blocks=blocks)
|
||||
|
||||
|
||||
def summarize_keywords(lines: Iterable[str], *, max_keywords: int = 6) -> list[str]:
|
||||
terms: dict[str, int] = {}
|
||||
for line in lines:
|
||||
for raw in line.split():
|
||||
cleaned = raw.strip().strip(",.;:()[]").lower()
|
||||
if len(cleaned) <= 2:
|
||||
continue
|
||||
terms[cleaned] = terms.get(cleaned, 0) + 1
|
||||
return [
|
||||
term
|
||||
for term, _ in sorted(terms.items(), key=lambda kv: kv[1], reverse=True)[
|
||||
:max_keywords
|
||||
]
|
||||
]
|
||||
53
dlib/cv/patcher.py
Normal file
53
dlib/cv/patcher.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from .schema import PatchOperation, PatchPayload, StructuredDocument
|
||||
|
||||
|
||||
def apply_patchset(
|
||||
document: StructuredDocument, patches: list[PatchPayload]
|
||||
) -> StructuredDocument:
|
||||
working = StructuredDocument.model_validate(deepcopy(document.model_dump()))
|
||||
for patch in patches:
|
||||
block = working.get_block(patch.target_path)
|
||||
if not block:
|
||||
continue
|
||||
if patch.operation == PatchOperation.REPLACE_TEXT:
|
||||
block.metadata["previous_text"] = block.text
|
||||
if patch.new_value:
|
||||
block.text = patch.new_value
|
||||
elif patch.operation == PatchOperation.REMOVE_BLOCK:
|
||||
working.blocks = [
|
||||
candidate
|
||||
for candidate in working.blocks
|
||||
if candidate.path != patch.target_path
|
||||
]
|
||||
elif patch.operation == PatchOperation.REORDER_SECTION:
|
||||
target_index = (
|
||||
patch.metadata.get("target_index") if patch.metadata else None
|
||||
)
|
||||
if target_index is None:
|
||||
continue
|
||||
to_move = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in working.blocks
|
||||
if candidate.path == patch.target_path
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not to_move:
|
||||
continue
|
||||
working.blocks = [
|
||||
candidate
|
||||
for candidate in working.blocks
|
||||
if candidate.path != patch.target_path
|
||||
]
|
||||
working.blocks.insert(int(target_index), to_move)
|
||||
elif patch.operation == PatchOperation.BOOST_KEYWORD and patch.new_value:
|
||||
if patch.new_value not in block.keywords:
|
||||
block.keywords.insert(0, patch.new_value)
|
||||
elif patch.operation == PatchOperation.SUPPRESS_BLOCK:
|
||||
block.metadata["suppressed"] = True
|
||||
return working
|
||||
54
dlib/cv/schema.py
Normal file
54
dlib/cv/schema.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class StructuredBlock(BaseModel):
|
||||
"""Editable slice of a DOCX document."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
path: str
|
||||
block_type: Literal[
|
||||
"heading", "summary", "bullet", "skills", "table", "meta", "text"
|
||||
]
|
||||
text: str
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StructuredDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
version_label: str | None = None
|
||||
blocks: list[StructuredBlock] = Field(default_factory=list)
|
||||
|
||||
def get_block(self, path: str) -> StructuredBlock | None:
|
||||
return next((block for block in self.blocks if block.path == path), None)
|
||||
|
||||
|
||||
class PatchOperation(str, Enum):
|
||||
REPLACE_TEXT = "replace_text"
|
||||
REMOVE_BLOCK = "remove_block"
|
||||
REORDER_SECTION = "reorder_section"
|
||||
BOOST_KEYWORD = "boost_keyword"
|
||||
SUPPRESS_BLOCK = "suppress_block"
|
||||
|
||||
|
||||
class PatchPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
target_path: str
|
||||
operation: PatchOperation
|
||||
new_value: str | None = None
|
||||
old_value: str | None = None
|
||||
rationale: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PatchSuggestion(PatchPayload):
|
||||
confidence: float | None = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
3
dlib/storage/__init__.py
Normal file
3
dlib/storage/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .s3 import S3StorageClient, S3StorageConfig
|
||||
|
||||
__all__ = ["S3StorageClient", "S3StorageConfig"]
|
||||
92
dlib/storage/minio.py
Normal file
92
dlib/storage/minio.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import BinaryIO
|
||||
from uuid import uuid4
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MinioStorageConfig:
|
||||
bucket_name: str
|
||||
region_name: str = "us-east-1"
|
||||
endpoint_url: str | None = None
|
||||
access_key_id: str | None = None
|
||||
secret_access_key: str | None = None
|
||||
path_prefix: str = "artifacts"
|
||||
|
||||
|
||||
class MinioStorageClient:
|
||||
def __init__(self, config: MinioStorageConfig):
|
||||
self.config = config
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
region_name=config.region_name,
|
||||
endpoint_url=config.endpoint_url,
|
||||
aws_access_key_id=config.access_key_id or os.getenv("MINIO_ROOT_USER"),
|
||||
aws_secret_access_key=config.secret_access_key
|
||||
or os.getenv("MINIO_ROOT_PASSWORD"),
|
||||
)
|
||||
|
||||
def build_key(
|
||||
self, *, stem: str | None = None, extension: str | None = None
|
||||
) -> str:
|
||||
suffix = extension or ""
|
||||
if suffix and not suffix.startswith("."):
|
||||
suffix = f".{suffix}"
|
||||
filename = f"{stem or uuid4().hex}{suffix or ''}"
|
||||
prefix = self.config.path_prefix.strip("/")
|
||||
return f"{prefix}/{filename}" if prefix else filename
|
||||
|
||||
def upload_bytes(
|
||||
self, *, key: str, data: bytes, content_type: str | None = None
|
||||
) -> str:
|
||||
content_type = (
|
||||
content_type or mimetypes.guess_type(key)[0] or "application/octet-stream"
|
||||
)
|
||||
self._client.put_object(
|
||||
Bucket=self.config.bucket_name, Key=key, Body=data, ContentType=content_type
|
||||
)
|
||||
return key
|
||||
|
||||
def upload_fileobj(
|
||||
self, *, fileobj: BinaryIO, key: str, content_type: str | None = None
|
||||
) -> str:
|
||||
content_type = (
|
||||
content_type or mimetypes.guess_type(key)[0] or "application/octet-stream"
|
||||
)
|
||||
self._client.upload_fileobj(
|
||||
fileobj,
|
||||
self.config.bucket_name,
|
||||
key,
|
||||
ExtraArgs={"ContentType": content_type},
|
||||
)
|
||||
return key
|
||||
|
||||
def generate_presigned_url(self, *, key: str, expires_in: int = 900) -> str | None:
|
||||
try:
|
||||
return self._client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self.config.bucket_name, "Key": key},
|
||||
ExpiresIn=int(timedelta(seconds=expires_in).total_seconds()),
|
||||
)
|
||||
except ClientError:
|
||||
return None
|
||||
|
||||
def delete_object(self, *, key: str) -> None:
|
||||
try:
|
||||
self._client.delete_object(Bucket=self.config.bucket_name, Key=key)
|
||||
except ClientError:
|
||||
pass
|
||||
|
||||
def download_bytes(self, *, key: str) -> bytes:
|
||||
response = self._client.get_object(Bucket=self.config.bucket_name, Key=key)
|
||||
body = response.get("Body")
|
||||
if body:
|
||||
return body.read()
|
||||
return b""
|
||||
27
docker/backend-fastapi.Dockerfile
Normal file
27
docker/backend-fastapi.Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml uv.lock requirements.txt ./
|
||||
RUN pip install uv && uv pip install --system -r requirements.txt
|
||||
|
||||
COPY alveslib ./alveslib
|
||||
COPY dlib ./dlib
|
||||
COPY apps/backend/fastapi ./apps/backend/fastapi
|
||||
|
||||
WORKDIR /app/apps/backend/fastapi
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
27
docker/webapp.Dockerfile
Normal file
27
docker/webapp.Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
FROM oven/bun:1.2 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY apps/webapp/package.json apps/webapp/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
FROM deps AS builder
|
||||
COPY apps/webapp ./
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.2-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/bun.lock ./bun.lock
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "run", "start"]
|
||||
56
docs/resume-branches/architecture.md
Normal file
56
docs/resume-branches/architecture.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Resume Branches Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Resume Branches treats a canonical ATS-safe DOCX as a source-of-truth document graph. The stack is split into a Next.js control plane, a FastAPI backend, and a Celery worker. MinIO hosts artifacts locally and can be swapped with any S3-compatible object storage in production.
|
||||
|
||||
## Services
|
||||
|
||||
- **apps/webapp** – Next.js 15 UI with a CV tree, upload workflow, specialization browser, and publish controls. It talks to the FastAPI backend via `NEXT_PUBLIC_API_BASE_URL`.
|
||||
- **apps/backend/fastapi** – FastAPI service that handles ingest, structured patch storage, AI suggestions, and publishing. SQLAlchemy models persist into Postgres. Storage relies on MinIO.
|
||||
- **apps/worker** – Celery worker for asynchronous DOCX parsing, keyword extraction, and optional AI tailoring loops. It consumes Redis as the broker backend.
|
||||
- **dlib** – Shared domain library including: structured DOCX parsing (`dlib.cv`), patch validation, ATS guardrails, storage adapter (`dlib.storage`), MinIO client, and auth utilities.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `cv_documents` – conceptual resume per owner.
|
||||
- `cv_versions` – every branch or specialization. Stores structured block snapshots and artifact pointers.
|
||||
- `cv_patches` – granular operations applied to a version.
|
||||
- `submissions` – leaf nodes representing a company/role tailoring.
|
||||
- `ai_suggestions` – AI proposals pending acceptance.
|
||||
- `public_assets` – immutable artifacts for published CV links.
|
||||
|
||||
## Flows
|
||||
|
||||
1. **Upload canonical DOCX**: The backend stores the raw file in MinIO, parses the blocks, and seeds the root branch version.
|
||||
2. **Create branches**: Provide a parent version + patch list. ATS guardrails enforce change budgets and ratio protections.
|
||||
3. **Tailoring**: Submit job description + focus keywords. Suggestions are produced via `dlib.ai.tailoring` and saved for review.
|
||||
4. **Publish**: Copy a version/submission artifact to a public slug (e.g., `https://cv.alves.world/cv/ml-engineer-stripe`).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `DATABASE_URL` – Async SQLAlchemy DSN (default local Postgres).
|
||||
- `MINIO_*` – Access, bucket, region, and endpoint for the object storage.
|
||||
- `PUBLIC_BASE_URL` / `CV_PUBLIC_DOMAIN` – Hostnames for publishable resumes.
|
||||
- `AUTH_*` – Optional OIDC issuer/audience. Disabled by default for local dev.
|
||||
- `NEXT_PUBLIC_API_BASE_URL` – Next.js uses this to call FastAPI.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make init
|
||||
make dev # Next.js
|
||||
make run.backend # FastAPI API server
|
||||
make run.worker # Celery worker
|
||||
make lift.database # Postgres profile
|
||||
make lift.minio # MinIO bucket
|
||||
```
|
||||
|
||||
## Dokploy Targets
|
||||
|
||||
- Backend (FastAPI) – build from `docker/backend-fastapi.Dockerfile`, run behind `api.cv.alves.world`.
|
||||
- Webapp (Next.js) – build from `docker/webapp.Dockerfile`, exposed via `cv.alves.world`.
|
||||
- Redis, Postgres, and MinIO run either inside Dokploy or via managed offerings.
|
||||
|
||||
See `docs/resume-branches/dokploy.md` for concrete API payloads.
|
||||
146
docs/resume-branches/dokploy.md
Normal file
146
docs/resume-branches/dokploy.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Dokploy Deployment Plan (cv.alves.world)
|
||||
|
||||
Default host: `https://dev.alves.world` (`DOKPLOY_API_BASE=https://dev.alves.world/api`). Authentication uses `x-api-key: $DOKPLOY_API` loaded from `.env`.
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. ./.env # must contain DOKPLOY_API
|
||||
set +a
|
||||
: "${DOKPLOY_API:?missing token}"
|
||||
DOKPLOY_BASE_URL="${DOKPLOY_BASE_URL:-https://dev.alves.world}"
|
||||
DOKPLOY_API_BASE="${DOKPLOY_BASE_URL%/}/api"
|
||||
```
|
||||
|
||||
## 1. Resolve IDs
|
||||
|
||||
Discover the Personal project and staging environment:
|
||||
|
||||
```bash
|
||||
curl -sS "$DOKPLOY_API_BASE/project.all" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $DOKPLOY_API" | jq '.[] | {id, name}'
|
||||
|
||||
PROJECT_ID="<personal-project-id>"
|
||||
|
||||
curl -sS "$DOKPLOY_API_BASE/environment.byProjectId?projectId=$PROJECT_ID" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $DOKPLOY_API" | jq '.[] | {id, name}'
|
||||
|
||||
ENVIRONMENT_ID="<personal-env-id>"
|
||||
```
|
||||
|
||||
## 2. Build + push images
|
||||
|
||||
Backend image (adjust registry/org as needed):
|
||||
|
||||
```bash
|
||||
docker build -t ghcr.io/<org>/resume-branches-backend:latest -f docker/backend-fastapi.Dockerfile .
|
||||
docker push ghcr.io/<org>/resume-branches-backend:latest
|
||||
```
|
||||
|
||||
Webapp image:
|
||||
|
||||
```bash
|
||||
docker build -t ghcr.io/<org>/resume-branches-webapp:latest -f docker/webapp.Dockerfile .
|
||||
docker push ghcr.io/<org>/resume-branches-webapp:latest
|
||||
```
|
||||
|
||||
## 3. Backend application
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$DOKPLOY_API_BASE/application.create" \
|
||||
-H "Content-Type: application/json" -H "x-api-key: $DOKPLOY_API" \
|
||||
-d @- <<'JSON'
|
||||
{
|
||||
"name": "resume-backend",
|
||||
"environmentId": "ENVIRONMENT_ID",
|
||||
"projectId": "PROJECT_ID",
|
||||
"deployment": {
|
||||
"type": "docker",
|
||||
"image": "ghcr.io/<org>/resume-branches-backend:latest",
|
||||
"env": {
|
||||
"BACKEND_PORT": "8080",
|
||||
"DATABASE_URL": "postgresql+asyncpg://postgres:postgres@postgres:5432/resume_branches",
|
||||
"MINIO_ENDPOINT": "http://minio:9000",
|
||||
"MINIO_BUCKET": "resume-branches",
|
||||
"MINIO_REGION": "us-east-1",
|
||||
"MINIO_ROOT_USER": "${MINIO_ROOT_USER}",
|
||||
"MINIO_ROOT_PASSWORD": "${MINIO_ROOT_PASSWORD}",
|
||||
"PUBLIC_BASE_URL": "https://cv.alves.world",
|
||||
"CV_PUBLIC_DOMAIN": "cv.alves.world"
|
||||
},
|
||||
"healthcheck": {
|
||||
"path": "/health",
|
||||
"interval": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Domain attach (api.cv.alves.world)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$DOKPLOY_API_BASE/domain.validateDomain" \
|
||||
-H "Content-Type: application/json" -H "x-api-key: $DOKPLOY_API" \
|
||||
-d '{"domain":"api.cv.alves.world"}'
|
||||
|
||||
curl -sS -X POST "$DOKPLOY_API_BASE/domain.create" \
|
||||
-H "Content-Type: application/json" -H "x-api-key: $DOKPLOY_API" \
|
||||
-d '{
|
||||
"host":"api.cv.alves.world",
|
||||
"https": true,
|
||||
"certificateType": "letsencrypt",
|
||||
"applicationId": "<backend-app-id>",
|
||||
"domainType": "application",
|
||||
"path": "/",
|
||||
"stripPath": false
|
||||
}'
|
||||
```
|
||||
|
||||
Deploy or redeploy:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$DOKPLOY_API_BASE/application.deploy" \
|
||||
-H "Content-Type: application/json" -H "x-api-key: $DOKPLOY_API" \
|
||||
-d '{"applicationId":"<backend-app-id>"}'
|
||||
```
|
||||
|
||||
## 4. Webapp application (cv.alves.world)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$DOKPLOY_API_BASE/application.create" -H "Content-Type: application/json" -H "x-api-key: $DOKPLOY_API" -d @- <<'JSON'
|
||||
{
|
||||
"name": "resume-webapp",
|
||||
"environmentId": "ENVIRONMENT_ID",
|
||||
"projectId": "PROJECT_ID",
|
||||
"deployment": {
|
||||
"type": "docker",
|
||||
"image": "ghcr.io/<org>/resume-branches-webapp:latest",
|
||||
"env": {
|
||||
"NEXT_PUBLIC_API_BASE_URL": "https://api.cv.alves.world"
|
||||
},
|
||||
"healthcheck": {
|
||||
"path": "/health",
|
||||
"interval": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Attach `cv.alves.world` to the webapp (same `domain.validateDomain` + `domain.create`).
|
||||
|
||||
## 5. Post-deploy verification
|
||||
|
||||
```bash
|
||||
curl https://api.cv.alves.world/health
|
||||
curl https://api.cv.alves.world/api/v1/documents -H "x-api-key: <if auth enforced>"
|
||||
curl https://cv.alves.world/dashboard
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep the Dokploy panel at `dev.alves.world`. Never assign production apps to that hostname.
|
||||
- If Postgres/Redis/MinIO run inside Dokploy, create additional compose services or dedicated applications and inject their service hostnames via env vars.
|
||||
- Once DNS is live, re-run `domain.validateDomain` until Dokploy issues certificates.
|
||||
@@ -15,10 +15,17 @@ dependencies = [
|
||||
"fastapi",
|
||||
"flask",
|
||||
"flask-cors",
|
||||
"httpx",
|
||||
"boto3",
|
||||
"pydantic",
|
||||
"pydantic-settings",
|
||||
"python-docx",
|
||||
"python-dotenv",
|
||||
"python-logging-loki",
|
||||
"pyyaml",
|
||||
"python-jose[cryptography]",
|
||||
"sqlalchemy[asyncio]",
|
||||
"asyncpg",
|
||||
"redis",
|
||||
"requests",
|
||||
"seleniumbase",
|
||||
@@ -44,7 +51,7 @@ dev = [
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["alveslib*"]
|
||||
include = ["alveslib*", "dlib*"]
|
||||
exclude = ["apps*", "ml*", "tests*"]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -11,8 +11,15 @@ python-logging-loki
|
||||
fastapi
|
||||
flask
|
||||
flask-cors
|
||||
sqlalchemy[asyncio]
|
||||
asyncpg
|
||||
httpx
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
python-docx
|
||||
boto3
|
||||
|
||||
# Worker (Celery + Redis)
|
||||
celery[redis]
|
||||
|
||||
Reference in New Issue
Block a user