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