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

117 lines
5.0 KiB
Python

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