mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 16:53:38 +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)
|
||||
|
||||
Reference in New Issue
Block a user