Finish MVP and dockerize

This commit is contained in:
2026-04-02 19:15:47 +02:00
parent 90ad5e0260
commit 30cb18b55e
50 changed files with 2346 additions and 17 deletions

View File

@@ -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

View File

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

View File

@@ -0,0 +1 @@
"""FastAPI application for Resume Branches control plane."""

View 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

View 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)

View File

@@ -0,0 +1,3 @@
from . import documents, versions, submissions, public
__all__ = ["documents", "versions", "submissions", "public"]

View 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)

View 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,
)

View 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]

View 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)

View 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]

View File

@@ -0,0 +1,8 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
from app import models # noqa: E402,F401

View 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

View 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)

View 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",
]

View 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"
)

View 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
)

View 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",
]

View 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

View 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()

View 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]

View 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)

View 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()

View 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

View File

@@ -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)

View File

@@ -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
View 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.

View 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>
)
}

View 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>
)
}

View 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 }

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
from .tailoring import generate_tailoring_suggestions, TailoringContext
__all__ = ["generate_tailoring_suggestions", "TailoringContext"]

134
dlib/ai/tailoring.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from .oidc import AuthenticatedUser, OidcTokenValidator, build_validator
__all__ = ["AuthenticatedUser", "OidcTokenValidator", "build_validator"]

92
dlib/auth/oidc.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
from .s3 import S3StorageClient, S3StorageConfig
__all__ = ["S3StorageClient", "S3StorageConfig"]

92
dlib/storage/minio.py Normal file
View 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""

View 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
View 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"]

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

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

View File

@@ -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]

View File

@@ -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]