"""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")