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:
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))
|
||||
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.
|
||||
@@ -18,4 +18,4 @@ export default async function DashboardPage() {
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user