"""File persistence, extraction, and parser injection for resume imports.""" from __future__ import annotations import hashlib import time 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 .llm_services import log_ai_event 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: total_started = time.perf_counter() 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) cache_hit = draft is not None extract_ms = 0 parse_ms = 0 validate_ms = 0 text_characters: int | None = None if draft is None: extract_started = time.perf_counter() text = extract_text(extension=extension, content=content) extract_ms = round((time.perf_counter() - extract_started) * 1000) text_characters = len(text) parse_started = time.perf_counter() draft = self.parser.parse(text=text, source_name=safe_name) parse_ms = round((time.perf_counter() - parse_started) * 1000) validate_started = time.perf_counter() self._validate_document(draft.document) validate_ms = round((time.perf_counter() - validate_started) * 1000) 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 storage_started = time.perf_counter() target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(content) storage_ms = round((time.perf_counter() - storage_started) * 1000) log_ai_event( "resume_import_prepared", extract_ms=extract_ms, parse_ms=parse_ms, validate_ms=validate_ms, storage_ms=storage_ms, total_ms=round((time.perf_counter() - total_started) * 1000), cache_hit=cache_hit, file_extension=extension, size_bytes=len(content), text_characters=text_characters, ) 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")