Download apps/cases/services/material/folder_scan_service.py from jun2024/fc: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/cases/services/material/folder_scan_service.py
- Command line
-
hf download hf://spaces/jun2024/fc/apps/cases/services/material/folder_scan_service.py
-
curl -L -o folder_scan_service.py https://huggingface.co/spaces/jun2024/fc/resolve/main/apps/cases/services/material/folder_scan_service.py
21.6 kB
| """案件文件夹自动捕获服务。""" | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import re | |
| from pathlib import Path | |
| from typing import Any | |
| from urllib.parse import urlencode | |
| from uuid import UUID | |
| from django.core.files.uploadedfile import SimpleUploadedFile | |
| from django.db import transaction | |
| from django.urls import reverse | |
| from django.utils import timezone | |
| from django.utils.translation import gettext_lazy as _ | |
| from apps.cases.models import Case, CaseFolderBinding, CaseFolderScanSession, CaseFolderScanStatus | |
| from apps.cases.services.log.caselog_service import CaseLogService | |
| from apps.core.dependencies.core import build_task_submission_service | |
| from apps.core.exceptions import NotFoundError, ValidationException | |
| from apps.core.services.bound_folder_scan_service import BoundFolderScanService | |
| logger = logging.getLogger(__name__) | |
| class CaseFolderScanService: | |
| """案件自动捕获扫描、轮询、导入附件服务。""" | |
| _FORCE_OUR_PARTY_FOLDER_KEYWORDS = ( | |
| "立案材料", | |
| "递交给法院的资料", | |
| "提交给法院的资料", | |
| ) | |
| _FORCE_OUR_PARTY_REASON = "命中目录规则:立案材料/提交给法院的资料目录默认归类为我方当事人材料" | |
| _ACTIVE_STATUSES = { | |
| CaseFolderScanStatus.PENDING, | |
| CaseFolderScanStatus.RUNNING, | |
| CaseFolderScanStatus.CLASSIFYING, | |
| } | |
| def __init__(self, *, scan_service: BoundFolderScanService | None = None) -> None: | |
| self._scan_service = scan_service or BoundFolderScanService() | |
| self._case_log_service = CaseLogService() | |
| def start_scan( | |
| self, | |
| *, | |
| case_id: int, | |
| started_by: Any | None, | |
| rescan: bool = False, | |
| scan_subfolder: str = "", | |
| enable_recognition: bool = False, | |
| ) -> CaseFolderScanSession: | |
| self._ensure_case_exists(case_id) | |
| binding = self._get_accessible_binding(case_id) | |
| scan_scope = self._resolve_scan_scope(binding.folder_path, scan_subfolder) | |
| scan_options = {"enable_recognition": bool(enable_recognition)} | |
| if not rescan: | |
| existing = ( | |
| CaseFolderScanSession.objects.filter(case_id=case_id, status__in=self._ACTIVE_STATUSES) | |
| .order_by("-created_at") | |
| .first() | |
| ) | |
| if existing: | |
| existing_subfolder = self._extract_scan_subfolder(existing.result_payload) | |
| existing_enable_recognition = self._extract_enable_recognition(existing.result_payload) | |
| if ( | |
| existing_subfolder == scan_scope["scan_subfolder"] | |
| and existing_enable_recognition == scan_options["enable_recognition"] | |
| ): | |
| return existing | |
| raise ValidationException( | |
| message=_("已有进行中的扫描任务,请等待完成或使用“重新扫描”"), | |
| errors={"session_id": str(existing.id)}, | |
| ) | |
| session = CaseFolderScanSession.objects.create( | |
| case_id=case_id, | |
| status=CaseFolderScanStatus.PENDING, | |
| progress=0, | |
| current_file="", | |
| result_payload={ | |
| "summary": {}, | |
| "candidates": [], | |
| "scan_scope": scan_scope, | |
| "scan_options": scan_options, | |
| }, | |
| started_by=started_by if getattr(started_by, "is_authenticated", False) else None, | |
| ) | |
| task_id = build_task_submission_service().submit( | |
| "apps.cases.services.material.folder_scan_service.run_case_folder_scan_task", | |
| args=[str(session.id)], | |
| task_name=f"case_folder_scan_{session.id}", | |
| ) | |
| CaseFolderScanSession.objects.filter(id=session.id).update( | |
| status=CaseFolderScanStatus.RUNNING, | |
| task_id=str(task_id), | |
| updated_at=timezone.now(), | |
| ) | |
| session.refresh_from_db() | |
| logger.info( | |
| "case_folder_scan_submitted", | |
| extra={ | |
| "case_id": case_id, | |
| "session_id": str(session.id), | |
| "scan_subfolder": scan_scope["scan_subfolder"], | |
| "enable_recognition": scan_options["enable_recognition"], | |
| }, | |
| ) | |
| return session | |
| def list_scan_subfolders(self, *, case_id: int) -> dict[str, Any]: | |
| self._ensure_case_exists(case_id) | |
| binding = self._get_accessible_binding(case_id) | |
| root = Path(binding.folder_path).expanduser().resolve() | |
| subfolders: list[dict[str, str]] = [] | |
| for child in sorted(root.iterdir(), key=lambda item: item.name.lower()): | |
| if child.name.startswith("."): | |
| continue | |
| if not child.is_dir(): | |
| continue | |
| resolved_child = child.resolve() | |
| if not self._is_within_root(root, resolved_child): | |
| continue | |
| subfolders.append( | |
| { | |
| "relative_path": child.name, | |
| "display_name": child.name, | |
| } | |
| ) | |
| return { | |
| "root_path": root.as_posix(), | |
| "subfolders": subfolders, | |
| } | |
| def get_session(self, *, case_id: int, session_id: UUID) -> CaseFolderScanSession: | |
| try: | |
| return CaseFolderScanSession.objects.get(id=session_id, case_id=case_id) | |
| except CaseFolderScanSession.DoesNotExist: | |
| raise NotFoundError(_("扫描会话不存在")) from None | |
| def build_status_payload(self, *, session: CaseFolderScanSession) -> dict[str, Any]: | |
| payload = dict(session.result_payload or {}) | |
| summary = payload.get("summary") or {} | |
| candidates = self._normalize_candidates_for_scan_scope(payload.get("candidates") or [], payload) | |
| stage_result = payload.get("stage_result") or {} | |
| prefill_map = stage_result.get("prefill_map") or {} | |
| scan_subfolder = self._extract_scan_subfolder(payload) | |
| enable_recognition = self._extract_enable_recognition(payload) | |
| return { | |
| "session_id": str(session.id), | |
| "status": session.status, | |
| "progress": int(session.progress or 0), | |
| "current_file": session.current_file or "", | |
| "scan_subfolder": scan_subfolder, | |
| "enable_recognition": enable_recognition, | |
| "summary": { | |
| "total_files": int(summary.get("total_files", 0) or 0), | |
| "deduped_files": int(summary.get("deduped_files", 0) or 0), | |
| "classified_files": int(summary.get("classified_files", 0) or 0), | |
| }, | |
| "candidates": candidates, | |
| "error_message": session.error_message or "", | |
| "prefill_map": prefill_map, | |
| } | |
| def stage_to_attachments( | |
| self, | |
| *, | |
| case_id: int, | |
| session_id: UUID, | |
| items: list[dict[str, Any]], | |
| user: Any | None, | |
| org_access: dict[str, Any] | None, | |
| perm_open_access: bool, | |
| ) -> dict[str, Any]: | |
| session = self.get_session(case_id=case_id, session_id=session_id) | |
| if session.status not in {CaseFolderScanStatus.COMPLETED, CaseFolderScanStatus.STAGED}: | |
| raise ValidationException(message=_("扫描尚未完成"), errors={"status": session.status}) | |
| payload = dict(session.result_payload or {}) | |
| candidates = payload.get("candidates") or [] | |
| candidate_map = {str(item.get("source_path") or ""): item for item in candidates} | |
| selected_items = [item for item in items if bool(item.get("selected", True))] | |
| if not selected_items: | |
| raise ValidationException(message=_("未找到可导入的 PDF")) | |
| log = self._case_log_service.create_log( | |
| case_id=case_id, | |
| content=str(_("自动捕获材料")), | |
| user=user, | |
| org_access=org_access, | |
| perm_open_access=perm_open_access, | |
| ) | |
| uploads: list[SimpleUploadedFile] = [] | |
| prefill_entries: list[dict[str, Any]] = [] | |
| for item in selected_items: | |
| source_path = str(item.get("source_path") or "").strip() | |
| if not source_path or source_path not in candidate_map: | |
| raise ValidationException(message=_("候选文件不存在"), errors={"source_path": source_path}) | |
| file_path = Path(source_path) | |
| if not file_path.exists() or not file_path.is_file(): | |
| raise ValidationException(message=_("源文件不存在"), errors={"source_path": source_path}) | |
| uploads.append( | |
| SimpleUploadedFile( | |
| name=file_path.name, | |
| content=file_path.read_bytes(), | |
| content_type="application/pdf", | |
| ) | |
| ) | |
| category = str(item.get("category") or "").strip() | |
| if category not in {"party", "non_party"}: | |
| category = "" | |
| side = str(item.get("side") or "").strip() | |
| if category != "party" or side not in {"our", "opponent"}: | |
| side = "" | |
| supervising_authority_id: int | None = None | |
| if category == "non_party": | |
| supervising_authority_id = self._to_int(item.get("supervising_authority_id")) | |
| party_ids: list[int] = [] | |
| if category == "party": | |
| raw_party_ids = item.get("party_ids") or [] | |
| if isinstance(raw_party_ids, list): | |
| seen_party_ids: set[int] = set() | |
| for raw_pid in raw_party_ids: | |
| pid = self._to_int(raw_pid) | |
| if pid and pid not in seen_party_ids: | |
| seen_party_ids.add(pid) | |
| party_ids.append(pid) | |
| prefill_entries.append( | |
| { | |
| "category": category, | |
| "side": side, | |
| "type_name_hint": str(item.get("type_name_hint") or "").strip(), | |
| "supervising_authority_id": supervising_authority_id, | |
| "party_ids": party_ids, | |
| } | |
| ) | |
| created_attachments = self._case_log_service.upload_attachments( | |
| log_id=log.id, | |
| files=uploads, | |
| user=user, | |
| org_access=org_access, | |
| perm_open_access=perm_open_access, | |
| ) | |
| prefill_map: dict[str, dict[str, Any]] = {} | |
| for attachment, prefill in zip(created_attachments, prefill_entries, strict=True): | |
| prefill_map[str(attachment.id)] = prefill | |
| materials_url = self._build_materials_url(case_id=case_id, session_id=session_id) | |
| stage_result = { | |
| "log_id": int(log.id), | |
| "attachment_ids": [int(att.id) for att in created_attachments], | |
| "prefill_map": prefill_map, | |
| "materials_url": materials_url, | |
| "staged_at": timezone.now().isoformat(), | |
| } | |
| payload["stage_result"] = stage_result | |
| CaseFolderScanSession.objects.filter(id=session.id).update( | |
| status=CaseFolderScanStatus.STAGED, | |
| progress=100, | |
| current_file="", | |
| result_payload=payload, | |
| error_message="", | |
| updated_at=timezone.now(), | |
| ) | |
| return { | |
| "session_id": str(session.id), | |
| "status": CaseFolderScanStatus.STAGED, | |
| "log_id": int(log.id), | |
| "attachment_ids": [int(att.id) for att in created_attachments], | |
| "materials_url": materials_url, | |
| "prefill_map": prefill_map, | |
| } | |
| def run_scan_task(self, *, session_id: str) -> None: | |
| session = CaseFolderScanSession.objects.select_related("case").filter(id=session_id).first() | |
| if not session: | |
| logger.warning("case_folder_scan_session_missing", extra={"session_id": session_id}) | |
| return | |
| try: | |
| binding = self._get_accessible_binding(session.case_id) | |
| payload = dict(session.result_payload or {}) | |
| scan_scope = self._resolve_scan_scope( | |
| binding.folder_path, | |
| self._extract_scan_subfolder(payload), | |
| ) | |
| enable_recognition = self._extract_enable_recognition(payload) | |
| classification_context = self._build_classification_context(session.case) | |
| def _progress(status: str, progress: int, current_file: str | None) -> None: | |
| mapped_status = CaseFolderScanStatus.RUNNING | |
| if status == "classifying": | |
| mapped_status = CaseFolderScanStatus.CLASSIFYING | |
| elif status == "completed": | |
| mapped_status = CaseFolderScanStatus.COMPLETED | |
| CaseFolderScanSession.objects.filter(id=session.id).update( | |
| status=mapped_status, | |
| progress=int(progress), | |
| current_file=current_file or "", | |
| updated_at=timezone.now(), | |
| ) | |
| result = self._scan_service.scan_folder( | |
| folder_path=scan_scope["scan_folder"], | |
| domain="case", | |
| progress_callback=_progress, | |
| enable_recognition=enable_recognition, | |
| classification_context=classification_context, | |
| scan_subfolder=scan_scope["scan_subfolder"], | |
| ) | |
| result["scan_scope"] = scan_scope | |
| result["scan_options"] = {"enable_recognition": enable_recognition} | |
| CaseFolderScanSession.objects.filter(id=session.id).update( | |
| status=CaseFolderScanStatus.COMPLETED, | |
| progress=100, | |
| current_file="", | |
| result_payload=result, | |
| error_message="", | |
| updated_at=timezone.now(), | |
| ) | |
| except Exception as exc: | |
| logger.exception("case_folder_scan_failed", extra={"session_id": session_id}) | |
| CaseFolderScanSession.objects.filter(id=session.id).update( | |
| status=CaseFolderScanStatus.FAILED, | |
| error_message=str(exc), | |
| updated_at=timezone.now(), | |
| ) | |
| def _ensure_case_exists(case_id: int) -> None: | |
| if Case.objects.filter(id=case_id).exists(): | |
| return | |
| raise NotFoundError(_("案件不存在")) | |
| def _get_accessible_binding(case_id: int) -> CaseFolderBinding: | |
| binding = CaseFolderBinding.objects.filter(case_id=case_id).first() | |
| if not binding: | |
| raise ValidationException(message=_("未绑定文件夹"), errors={"case_id": case_id}) | |
| folder = Path(binding.folder_path) | |
| if not folder.exists() or not folder.is_dir(): | |
| raise ValidationException(message=_("绑定文件夹不可访问"), errors={"folder_path": binding.folder_path}) | |
| return binding | |
| def _extract_scan_subfolder(self, payload: dict[str, Any] | None) -> str: | |
| scope = (payload or {}).get("scan_scope") or {} | |
| return str(scope.get("scan_subfolder") or "").strip() | |
| def _normalize_candidates_for_scan_scope( | |
| self, | |
| candidates: list[dict[str, Any]], | |
| payload: dict[str, Any] | None, | |
| ) -> list[dict[str, Any]]: | |
| if not candidates: | |
| return [] | |
| should_force_for_scope = self._should_force_our_party_for_filing_materials(payload) | |
| normalized: list[dict[str, Any]] = [] | |
| for item in candidates: | |
| candidate = dict(item or {}) | |
| should_force_for_candidate = self._should_force_our_party_for_candidate(candidate) | |
| if not should_force_for_scope and not should_force_for_candidate: | |
| normalized.append(candidate) | |
| continue | |
| candidate["suggested_category"] = "party" | |
| candidate["suggested_side"] = "our" | |
| if not str(candidate.get("reason") or "").strip(): | |
| candidate["reason"] = self._FORCE_OUR_PARTY_REASON | |
| normalized.append(candidate) | |
| return normalized | |
| def _should_force_our_party_for_filing_materials(self, payload: dict[str, Any] | None) -> bool: | |
| scope = (payload or {}).get("scan_scope") or {} | |
| scan_subfolder = str(scope.get("scan_subfolder") or "").strip() | |
| scan_folder = str(scope.get("scan_folder") or "").strip() | |
| return self._contains_force_our_party_folder_keyword( | |
| scan_subfolder | |
| ) or self._contains_force_our_party_folder_keyword(scan_folder) | |
| def _should_force_our_party_for_candidate(self, candidate: dict[str, Any] | None) -> bool: | |
| source_path = str((candidate or {}).get("source_path") or "").strip() | |
| if not source_path: | |
| return False | |
| return self._contains_force_our_party_folder_keyword(source_path) | |
| def _contains_force_our_party_folder_keyword(cls, text: str) -> bool: | |
| normalized = str(text or "").strip() | |
| if not normalized: | |
| return False | |
| return any(keyword in normalized for keyword in cls._FORCE_OUR_PARTY_FOLDER_KEYWORDS) | |
| def _extract_enable_recognition(self, payload: dict[str, Any] | None) -> bool: | |
| options = (payload or {}).get("scan_options") or {} | |
| if "enable_recognition" not in options: | |
| return True | |
| return bool(options.get("enable_recognition")) | |
| def _resolve_scan_scope(self, root_folder: str, scan_subfolder: str) -> dict[str, str]: | |
| root = Path(root_folder).expanduser().resolve() | |
| normalized_subfolder = self._normalize_scan_subfolder(scan_subfolder) | |
| scan_path = root | |
| if normalized_subfolder: | |
| scan_path = (root / normalized_subfolder).resolve() | |
| if not self._is_within_root(root, scan_path): | |
| raise ValidationException( | |
| message=_("扫描子文件夹越界"), | |
| errors={"scan_subfolder": normalized_subfolder}, | |
| ) | |
| if not scan_path.exists() or not scan_path.is_dir(): | |
| raise ValidationException( | |
| message=_("扫描子文件夹不可访问"), | |
| errors={"scan_subfolder": normalized_subfolder}, | |
| ) | |
| return { | |
| "root_folder": root.as_posix(), | |
| "scan_folder": scan_path.as_posix(), | |
| "scan_subfolder": normalized_subfolder, | |
| } | |
| def _normalize_scan_subfolder(self, scan_subfolder: str) -> str: | |
| raw = str(scan_subfolder or "").strip().replace("\\", "/") | |
| if not raw: | |
| return "" | |
| if raw.startswith("/") or raw.startswith("~") or re.match(r"^[A-Za-z]:/", raw): | |
| raise ValidationException(message=_("扫描子文件夹必须使用相对路径"), errors={"scan_subfolder": raw}) | |
| parts = [part for part in raw.split("/") if part not in {"", "."}] | |
| if not parts: | |
| return "" | |
| if any(part == ".." for part in parts): | |
| raise ValidationException(message=_("扫描子文件夹路径非法"), errors={"scan_subfolder": raw}) | |
| return "/".join(parts) | |
| def _is_within_root(self, root: Path, target: Path) -> bool: | |
| try: | |
| return os.path.commonpath([root.as_posix(), target.as_posix()]) == root.as_posix() | |
| except ValueError: | |
| return False | |
| def _build_classification_context(self, case: Case) -> dict[str, Any]: | |
| our_party_ids: list[int] = [] | |
| opponent_party_ids: list[int] = [] | |
| our_party_names: list[str] = [] | |
| opponent_party_names: list[str] = [] | |
| for party in case.parties.select_related("client").all(): | |
| client = getattr(party, "client", None) | |
| if not client: | |
| continue | |
| party_id = int(getattr(party, "id", 0) or 0) | |
| party_name = str(getattr(client, "name", "") or "").strip() | |
| is_our = bool(getattr(client, "is_our_client", False)) | |
| if is_our: | |
| if party_id: | |
| our_party_ids.append(party_id) | |
| if party_name: | |
| our_party_names.append(party_name) | |
| else: | |
| if party_id: | |
| opponent_party_ids.append(party_id) | |
| if party_name: | |
| opponent_party_names.append(party_name) | |
| authority_ids = [int(auth.id) for auth in case.supervising_authorities.all()] | |
| primary_authority_id = authority_ids[0] if authority_ids else None | |
| return { | |
| "our_party_ids": our_party_ids, | |
| "opponent_party_ids": opponent_party_ids, | |
| "our_party_names": our_party_names, | |
| "opponent_party_names": opponent_party_names, | |
| "supervising_authority_ids": authority_ids, | |
| "primary_supervising_authority_id": primary_authority_id, | |
| } | |
| def _to_int(value: Any) -> int | None: | |
| try: | |
| parsed = int(value) | |
| except (TypeError, ValueError): | |
| return None | |
| if parsed <= 0: | |
| return None | |
| return parsed | |
| def _build_materials_url(*, case_id: int, session_id: UUID) -> str: | |
| base = reverse("admin:cases_case_materials", args=[case_id]) | |
| return f"{base}?{urlencode({'scan_session': str(session_id)})}" | |
| def run_case_folder_scan_task(session_id: str) -> None: | |
| """Django-Q 任务入口。""" | |
| CaseFolderScanService().run_scan_task(session_id=session_id) | |