Files
resume-agent/backend/app/resume_import_service.py
T

88 lines
3.8 KiB
Python

"""File persistence, extraction, and parser injection for resume imports."""
from __future__ import annotations
import hashlib
from collections import OrderedDict
from pathlib import Path
from typing import Protocol
from uuid import uuid4
from .document_extractors import extract_text, normalize_upload_name, validate_upload
from .import_parser_fast import slim_parser
from .resume_import_models import ParsedResumeDraft
from .resume_import_rules import parse_resume_text
MAX_IMPORT_BYTES = 10 * 1024 * 1024
_PARSE_CACHE_SIZE = 64
class ResumeImportParser(Protocol):
def parse(self, *, text: str, source_name: str) -> ParsedResumeDraft: ...
class RuleBasedResumeImportParser:
"""Local structured fallback when the model parser is unavailable."""
def parse(self, *, text: str, source_name: str) -> ParsedResumeDraft:
return parse_resume_text(text=text, source_name=source_name)
class ResumeImportService:
def __init__(self, *, storage_root: str | Path, parser: ResumeImportParser | None = None) -> None:
self.storage_root = Path(storage_root)
# OpenAI parsers are wrapped in the slim-schema variant (no model-emitted
# evidence quotes; roughly half the output tokens and latency).
self.parser = slim_parser(parser) if parser is not None else RuleBasedResumeImportParser()
# Re-uploading an unchanged file must not re-run the LLM parse; keyed by
# content hash so the cache works across sessions. Process-local by design.
self._parse_cache: OrderedDict[str, ParsedResumeDraft] = OrderedDict()
def prepare(self, *, file_name: str, declared_mime: str | None, content: bytes) -> dict:
if len(content) > MAX_IMPORT_BYTES:
raise ValueError("import_file_too_large")
safe_name, extension = normalize_upload_name(file_name)
mime_type = validate_upload(extension=extension, declared_mime=declared_mime, content=content)
sha256 = hashlib.sha256(content).hexdigest()
draft = self._parse_cache.get(sha256)
if draft is None:
text = extract_text(extension=extension, content=content)
draft = self.parser.parse(text=text, source_name=safe_name)
self._validate_document(draft.document)
self._parse_cache[sha256] = draft
self._parse_cache.move_to_end(sha256)
while len(self._parse_cache) > _PARSE_CACHE_SIZE:
self._parse_cache.popitem(last=False)
else:
self._parse_cache.move_to_end(sha256)
draft = draft.model_copy(deep=True)
object_key = f"{sha256[:2]}/{uuid4().hex}{extension}"
target = self.storage_root / object_key
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return {
"file_name": safe_name,
"mime_type": mime_type,
"size_bytes": len(content),
"sha256": sha256,
"object_key": object_key,
"document": draft.document,
"field_reviews": [item.model_dump(mode="json") for item in draft.field_reviews],
}
def remove(self, object_key: str | None) -> None:
if not object_key:
return
path = (self.storage_root / object_key).resolve()
root = self.storage_root.resolve()
if root not in path.parents:
return
path.unlink(missing_ok=True)
@staticmethod
def _validate_document(document: dict) -> None:
required = {"schema_version", "basics", "target", "sections", "skill_groups"}
if document.get("schema_version") != 3 or not required.issubset(document):
raise ValueError("invalid_import_document")
if not isinstance(document["sections"], list) or not isinstance(document["skill_groups"], list):
raise ValueError("invalid_import_document")