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:
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
|
||||
Reference in New Issue
Block a user