generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
88 lines
3.8 KiB
Python
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")
|