Finish MVP and dockerize

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

View File

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

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.db.session import AsyncSessionLocal
from dlib.auth import AuthenticatedUser, build_validator
security_scheme = HTTPBearer(auto_error=False)
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
@lru_cache(maxsize=1)
def _validator():
settings = get_settings()
return build_validator(
issuer=settings.auth_oidc_issuer,
audience=settings.auth_oidc_audience,
disable=settings.auth_disable_verification,
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme),
) -> AuthenticatedUser:
validator = _validator()
token = credentials.credentials if credentials else ""
try:
return await validator.validate(token)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.routes import documents, versions, submissions, public
api_router = APIRouter()
api_router.include_router(documents.router)
api_router.include_router(versions.router)
api_router.include_router(submissions.router)
api_router.include_router(public.router)

View File

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

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import DocumentListResponse, DocumentResponse
from app.services.documents import create_document, get_document, list_documents
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/documents", tags=["documents"])
@router.get("/", response_model=DocumentListResponse)
async def list_user_documents(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
documents = await list_documents(session, owner_id=user.sub)
payload = [DocumentResponse.model_validate(doc) for doc in documents]
return DocumentListResponse(items=payload)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_user_document(
document_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await get_document(session, owner_id=user.sub, document_id=document_id)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.model_validate(document)
@router.post("/", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),
description: str | None = Form(default=None),
file: UploadFile = File(...),
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
document = await create_document(
session,
owner_id=user.sub,
title=title,
description=description,
upload=file,
)
return DocumentResponse.model_validate(document)

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.services.publication import publish_version
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/public", tags=["public"])
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await publish_version(
session,
owner_id=user.sub,
version_id=payload.version_id,
submission_id=payload.submission_id,
slug=payload.slug,
)
if not asset:
raise HTTPException(status_code=404, detail="Version or submission not found")
return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,
artifact_key=asset.artifact_key,
is_public=asset.is_public,
created_at=asset.created_at,
version_id=asset.version_id,
submission_id=asset.submission_id,
url=url,
)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import (
AiSuggestionRequest,
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
)
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.post("/", response_model=SubmissionResponse)
async def create_submission_endpoint(
payload: SubmissionCreateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await create_submission(
session,
owner_id=user.sub,
version_id=payload.version_id,
company_name=payload.company_name,
role_title=payload.role_title,
job_url=payload.job_url,
job_description=payload.job_description,
)
if not submission:
raise HTTPException(status_code=404, detail="Version not found")
return SubmissionResponse.model_validate(submission)
@router.post("/{submission_id}/ai", response_model=list[SuggestionResponse])
async def request_submissions_ai(
submission_id: str,
payload: AiSuggestionRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestions = await request_ai_suggestions(
session,
owner_id=user.sub,
submission_id=submission_id,
job_description=payload.job_description,
focus_keywords=payload.focus_keywords,
)
if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions]

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch
from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/versions", tags=["versions"])
@router.post("/branches", response_model=VersionResponse)
async def create_version_branch(
payload: BranchCreateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
if not version:
raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version)

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from functools import lru_cache
from typing import List
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
project_name: str = "Resume Branches API"
api_prefix: str = "/api/v1"
environment: str = Field(default="local", alias="ENVIRONMENT")
database_url: str = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches",
alias="DATABASE_URL",
)
cors_origins: List[str] = Field(
default_factory=lambda: ["http://localhost:3000"], alias="CORS_ORIGINS"
)
storage_bucket: str = Field(default="resume-branches", alias="MINIO_BUCKET")
storage_region: str = Field(default="us-east-1", alias="MINIO_REGION")
storage_endpoint_url: str | None = Field(
default="http://localhost:9900", alias="MINIO_ENDPOINT"
)
storage_access_key: str | None = Field(default=None, alias="MINIO_ROOT_USER")
storage_secret_key: str | None = Field(default=None, alias="MINIO_ROOT_PASSWORD")
storage_path_prefix: str = Field(default="artifacts/cv")
auth_oidc_issuer: str | None = Field(default=None, alias="AUTH_OIDC_ISSUER")
auth_oidc_audience: str | None = Field(default=None, alias="AUTH_OIDC_AUDIENCE")
auth_disable_verification: bool = Field(
default=True, alias="AUTH_DISABLE_VERIFICATION"
)
celery_broker_url: str = Field(
default="redis://localhost:6379/0", alias="CELERY_BROKER_URL"
)
celery_result_backend: str | None = Field(
default=None, alias="CELERY_RESULT_BACKEND"
)
public_base_url: str = Field(
default="https://cv.alves.world", alias="PUBLIC_BASE_URL"
)
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
class Config:
env_file = ".env"
extra = "ignore"
@field_validator("cors_origins", mode="before")
@classmethod
def _split_origins(cls, value):
if isinstance(value, str):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings() # type: ignore[arg-type]

View File

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

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import get_settings
from app.db.base import Base
settings = get_settings()
engine = create_async_engine(
settings.database_url, echo=settings.environment == "local", future=True
)
AsyncSessionLocal = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
@asynccontextmanager
async def lifespan(app): # pragma: no cover
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async def get_session() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.db.session import lifespan
settings = get_settings()
app = FastAPI(title=settings.project_name, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.include_router(api_router, prefix=settings.api_prefix)

View File

@@ -0,0 +1,21 @@
from .cv import (
AiSuggestion,
CvDocument,
CvPatch,
CvVersion,
PublicAsset,
Specialization,
Submission,
SubmissionStatus,
)
__all__ = [
"CvDocument",
"CvVersion",
"CvPatch",
"Specialization",
"Submission",
"SubmissionStatus",
"PublicAsset",
"AiSuggestion",
]

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
import enum
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import IdentifierMixin, TimestampMixin
class CvDocument(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "cv_documents"
owner_id: Mapped[str] = mapped_column(String(255), index=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
root_version_id: Mapped[str | None] = mapped_column(
ForeignKey("cv_versions.id"), nullable=True
)
versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion", back_populates="document"
)
class CvVersion(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "cv_versions"
document_id: Mapped[str] = mapped_column(
ForeignKey("cv_documents.id", ondelete="CASCADE")
)
parent_version_id: Mapped[str | None] = mapped_column(
ForeignKey("cv_versions.id"), nullable=True
)
branch_name: Mapped[str] = mapped_column(String(120), default="root")
version_label: Mapped[str | None] = mapped_column(String(120), nullable=True)
artifact_docx_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
preview_html_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
structured_blocks: Mapped[list[dict] | None] = mapped_column(JSONB, default=list)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
document: Mapped[CvDocument] = relationship("CvDocument", back_populates="versions")
parent: Mapped["CvVersion" | None] = relationship(
"CvVersion", remote_side="CvVersion.id"
)
patches: Mapped[list["CvPatch"]] = relationship("CvPatch", back_populates="version")
submissions: Mapped[list["Submission"]] = relationship(
"Submission", back_populates="version"
)
class CvPatch(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "cv_patches"
version_id: Mapped[str] = mapped_column(
ForeignKey("cv_versions.id", ondelete="CASCADE")
)
target_path: Mapped[str] = mapped_column(String(255))
operation: Mapped[str] = mapped_column(String(64))
old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
version: Mapped[CvVersion] = relationship("CvVersion", back_populates="patches")
class Specialization(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "specializations"
document_id: Mapped[str] = mapped_column(
ForeignKey("cv_documents.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
based_on_version_id: Mapped[str | None] = mapped_column(
ForeignKey("cv_versions.id"), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
class SubmissionStatus(str, enum.Enum):
draft = "draft"
tailoring = "tailoring"
pending_review = "pending_review"
published = "published"
archived = "archived"
class Submission(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "submissions"
version_id: Mapped[str] = mapped_column(
ForeignKey("cv_versions.id", ondelete="CASCADE")
)
company_name: Mapped[str] = mapped_column(String(160))
role_title: Mapped[str] = mapped_column(String(160))
job_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
job_description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[SubmissionStatus] = mapped_column(
Enum(SubmissionStatus), default=SubmissionStatus.draft
)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
version: Mapped[CvVersion] = relationship("CvVersion", back_populates="submissions")
suggestions: Mapped[list["AiSuggestion"]] = relationship(
"AiSuggestion", back_populates="submission"
)
public_asset: Mapped["PublicAsset" | None] = relationship(
"PublicAsset", back_populates="submission"
)
class PublicAsset(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "public_assets"
submission_id: Mapped[str | None] = mapped_column(
ForeignKey("submissions.id", ondelete="SET NULL"), nullable=True
)
version_id: Mapped[str | None] = mapped_column(
ForeignKey("cv_versions.id"), nullable=True
)
slug: Mapped[str] = mapped_column(String(160), unique=True, index=True)
artifact_key: Mapped[str] = mapped_column(String(512))
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
expires_at: Mapped[str | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
submission: Mapped[Submission | None] = relationship(
"Submission", back_populates="public_asset"
)
version: Mapped[CvVersion | None] = relationship("CvVersion")
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):
__tablename__ = "ai_suggestions"
submission_id: Mapped[str] = mapped_column(
ForeignKey("submissions.id", ondelete="CASCADE")
)
target_path: Mapped[str] = mapped_column(String(255))
operation: Mapped[str] = mapped_column(String(64))
proposed_text: Mapped[str | None] = mapped_column(Text, nullable=True)
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
accepted: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSONB, default=dict)
submission: Mapped[Submission] = relationship(
"Submission", back_populates="suggestions"
)

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
class IdentifierMixin:
id: Mapped[str] = mapped_column(
UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
)

View File

@@ -0,0 +1,29 @@
from .cv import (
AiSuggestionRequest,
BranchCreateRequest,
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
SubmissionCreateRequest,
SubmissionResponse,
SuggestionResponse,
VersionResponse,
)
__all__ = [
"DocumentResponse",
"DocumentListResponse",
"DocumentCreateResult",
"VersionResponse",
"BranchCreateRequest",
"SubmissionCreateRequest",
"SubmissionResponse",
"AiSuggestionRequest",
"SuggestionResponse",
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
]

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from dlib.cv.schema import PatchSuggestion, StructuredBlock
class DocumentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
title: str
description: str | None
owner_id: str
root_version_id: str | None
created_at: datetime
updated_at: datetime
versions: list["VersionResponse"] = Field(default_factory=list)
class VersionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
branch_name: str
version_label: str | None
parent_version_id: str | None
structured_blocks: list[StructuredBlock] | None = None
artifact_docx_key: str | None = None
preview_html_key: str | None = None
created_at: datetime
updated_at: datetime
patches: list["PatchResponse"] = Field(default_factory=list)
class PatchResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
target_path: str
operation: str
old_value: str | None = None
new_value: str | None = None
metadata_json: dict[str, Any] | None = None
created_at: datetime
class DocumentListResponse(BaseModel):
items: list[DocumentResponse]
class DocumentCreateResult(BaseModel):
document: DocumentResponse
class BranchCreateRequest(BaseModel):
parent_version_id: str
branch_name: str
version_label: str | None = None
patches: list[dict[str, Any]] = Field(default_factory=list)
class SubmissionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
version_id: str
company_name: str
role_title: str
job_url: str | None = None
job_description: str | None = None
status: str
created_at: datetime
suggestions: list["SuggestionResponse"] = Field(default_factory=list)
class SubmissionCreateRequest(BaseModel):
version_id: str
company_name: str
role_title: str
job_url: str | None = None
job_description: str | None = None
class SuggestionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
target_path: str
operation: str
proposed_text: str | None
rationale: str | None
accepted: bool | None
metadata_json: dict[str, Any] | None = None
class AiSuggestionRequest(BaseModel):
job_description: str
focus_keywords: list[str] = Field(default_factory=list)
class PublishRequest(BaseModel):
version_id: str | None = None
submission_id: str | None = None
slug: str | None = None
class PublicAssetResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
slug: str
artifact_key: str
is_public: bool
created_at: datetime
version_id: str | None = None
submission_id: str | None = None
url: str | None = None
class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.cv import parse_docx_bytes
from app.models import CvDocument, CvVersion
from app.services.storage import persist_upload
async def create_document(
session: AsyncSession,
*,
owner_id: str,
title: str,
description: str | None,
upload: UploadFile,
) -> CvDocument:
artifact_key, file_bytes = await persist_upload(upload, owner_id)
structured = parse_docx_bytes(file_bytes, version_label="root")
doc = CvDocument(owner_id=owner_id, title=title, description=description)
version = CvVersion(
document=doc,
branch_name="root",
version_label="root",
artifact_docx_key=artifact_key,
structured_blocks=[block.model_dump() for block in structured.blocks],
metadata_json={"ingested": True},
)
doc.versions.append(version)
doc.root_version_id = version.id
session.add(doc)
await session.commit()
await session.refresh(doc, attribute_names=["versions"])
return doc
async def list_documents(session: AsyncSession, owner_id: str) -> list[CvDocument]:
stmt = (
select(CvDocument)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
.order_by(CvDocument.created_at.desc())
)
result = await session.execute(stmt)
return result.scalars().unique().all()
async def get_document(
session: AsyncSession, owner_id: str, document_id: str
) -> CvDocument | None:
stmt = (
select(CvDocument)
.where(CvDocument.id == document_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvDocument.versions).selectinload(CvVersion.patches))
)
result = await session.execute(stmt)
return result.scalars().unique().one_or_none()

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import re
from datetime import datetime
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CvDocument, CvVersion, PublicAsset, Submission
async def publish_version(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None,
submission_id: str | None,
slug: str | None,
) -> PublicAsset | None:
target_version: CvVersion | None = None
target_submission: Submission | None = None
if submission_id:
stmt = (
select(Submission)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
target_submission = result.scalars().one_or_none()
target_version = target_submission.version if target_submission else None
elif version_id:
stmt = (
select(CvVersion)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
target_version = result.scalars().one_or_none()
else:
return None
if not target_version or not target_version.artifact_docx_key:
return None
resolved_slug = slugify(slug or target_version.version_label or "cv")
if not resolved_slug:
resolved_slug = f"cv-{uuid4().hex[:6]}"
asset = PublicAsset(
submission_id=target_submission.id if target_submission else None,
version_id=target_version.id,
slug=resolved_slug,
artifact_key=target_version.artifact_docx_key,
is_public=True,
expires_at=None,
)
session.add(asset)
await session.commit()
await session.refresh(asset)
return asset
def slugify(value: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9-]+", "-", value.strip().lower())
safe = re.sub(r"-+", "-", safe).strip("-")
return safe[:120]

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Tuple
from fastapi import UploadFile
from app.core.config import get_settings
from dlib.storage import MinioStorageClient, MinioStorageConfig
def _build_storage_client() -> MinioStorageClient:
settings = get_settings()
config = MinioStorageConfig(
bucket_name=settings.storage_bucket,
region_name=settings.storage_region,
endpoint_url=settings.storage_endpoint_url,
access_key_id=settings.storage_access_key,
secret_access_key=settings.storage_secret_key,
path_prefix=settings.storage_path_prefix,
)
return MinioStorageClient(config)
storage_client = _build_storage_client()
def build_artifact_key(owner_id: str, suffix: str) -> str:
safe_suffix = suffix.lstrip(".") if suffix else ""
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
return storage_client.build_key(
stem=f"{owner_id}-{timestamp}", extension=safe_suffix
)
async def persist_upload(upload: UploadFile, owner_id: str) -> Tuple[str, bytes]:
data = await upload.read()
suffix = Path(upload.filename or "document.docx").suffix or ".docx"
key = build_artifact_key(owner_id, suffix)
storage_client.upload_bytes(key=key, data=data, content_type=upload.content_type)
return key, data
def build_public_url(key: str) -> str | None:
settings = get_settings()
if settings.storage_endpoint_url:
# Object storage accessible through endpoint URL
base = settings.storage_endpoint_url.rstrip("/")
return f"{base}/{settings.storage_bucket}/{key}"
return storage_client.generate_presigned_url(key=key, expires_in=3600)

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.ai.tailoring import TailoringContext, generate_tailoring_suggestions
from dlib.cv import StructuredBlock, StructuredDocument
from app.models import AiSuggestion, CvDocument, CvVersion, Submission, SubmissionStatus
async def create_submission(
session: AsyncSession,
*,
owner_id: str,
version_id: str,
company_name: str,
role_title: str,
job_url: str | None,
job_description: str | None,
) -> Submission | None:
version = await _get_version_for_owner(session, owner_id, version_id)
if not version:
return None
submission = Submission(
version_id=version.id,
company_name=company_name,
role_title=role_title,
job_url=job_url,
job_description=job_description,
status=SubmissionStatus.draft,
)
session.add(submission)
await session.commit()
await session.refresh(submission)
return submission
async def request_ai_suggestions(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
job_description: str,
focus_keywords: list[str],
) -> list[AiSuggestion] | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
version = submission.version
document = StructuredDocument(
version_label=version.version_label,
blocks=[
StructuredBlock.model_validate(block)
for block in version.structured_blocks or []
],
)
context = TailoringContext(
job_description=job_description, focus_keywords=focus_keywords
)
suggestions = generate_tailoring_suggestions(context, document)
if not suggestions:
return []
submission.status = SubmissionStatus.tailoring
created: list[AiSuggestion] = []
for suggestion in suggestions:
ai_row = AiSuggestion(
submission_id=submission.id,
target_path=suggestion.target_path,
operation=suggestion.operation.value,
proposed_text=suggestion.new_value,
rationale=suggestion.rationale,
metadata_json={
"keywords": suggestion.keywords,
"confidence": suggestion.confidence,
},
)
session.add(ai_row)
created.append(ai_row)
await session.commit()
for row in created:
await session.refresh(row)
return created
async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvDocument)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
)
result = await session.execute(stmt)
return result.scalars().one_or_none()
async def _get_submission_for_owner(
session: AsyncSession,
owner_id: str,
submission_id: str,
) -> Submission | None:
stmt = (
select(Submission)
.join(CvVersion)
.join(CvDocument)
.where(Submission.id == submission_id, CvDocument.owner_id == owner_id)
.options(selectinload(Submission.version))
)
result = await session.execute(stmt)
return result.scalars().one_or_none()

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from dlib.cv import (
StructuredBlock,
StructuredDocument,
PatchPayload,
apply_patchset,
validate_patchset,
)
from app.models import CvDocument, CvPatch, CvVersion
async def create_branch(
session: AsyncSession,
*,
owner_id: str,
parent_version_id: str,
branch_name: str,
version_label: str | None,
patches: list[dict],
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvDocument)
.where(CvVersion.id == parent_version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches))
)
result = await session.execute(stmt)
parent = result.scalars().one_or_none()
if not parent:
return None
base_doc = StructuredDocument(
version_label=parent.version_label,
blocks=[
StructuredBlock.model_validate(block)
for block in parent.structured_blocks or []
],
)
patch_models = [PatchPayload.model_validate(item) for item in patches]
if patch_models:
validate_patchset(base_doc, patch_models)
updated_doc = apply_patchset(base_doc, patch_models)
else:
updated_doc = base_doc
new_version = CvVersion(
document_id=parent.document_id,
parent_version_id=parent.id,
branch_name=branch_name,
version_label=version_label or branch_name,
artifact_docx_key=parent.artifact_docx_key,
structured_blocks=[block.model_dump() for block in updated_doc.blocks],
metadata_json={"patch_count": len(patch_models)},
)
session.add(new_version)
await session.flush()
for patch in patch_models:
session.add(
CvPatch(
version_id=new_version.id,
target_path=patch.target_path,
operation=patch.operation.value,
old_value=patch.old_value,
new_value=patch.new_value,
metadata_json=patch.metadata,
)
)
await session.commit()
await session.refresh(new_version)
return new_version