| import hashlib |
| import importlib |
| import json |
| import unicodedata |
| import zipfile |
| from dataclasses import dataclass |
| from datetime import UTC, datetime |
| from io import BytesIO |
| from pathlib import Path, PurePath |
| from secrets import token_urlsafe |
| from uuid import uuid4 |
| from xml.etree import ElementTree |
|
|
| from pydantic import ValidationError |
| from sqlalchemy import select |
| from sqlalchemy.orm import Session |
|
|
| from src.config import Settings, get_settings |
| from src.models.auth import AuthorityLevel, SourceDocumentStatus, SourceScope |
| from src.models.schemas import ( |
| LEGAL_SOURCE_TYPE_LABELS_VI, |
| DocumentListItem, |
| DocumentMetadata, |
| DocumentType, |
| EffectiveStatus, |
| IngestionStatus, |
| LegalChunk, |
| LegalDocument, |
| LegalParseFlag, |
| LegalSourceType, |
| Purpose, |
| RetentionPolicy, |
| ScopedDocumentItem, |
| utc_now_iso, |
| ) |
| from src.services.citations import CitationService |
| from src.services.db import DocumentChunkRecord, new_id, utc_now |
| from src.services.db import DocumentRecord as DbDocumentRecord |
| from src.services.embeddings import has_real_api_key |
| from src.services.legal_chunking import LegalChunker |
|
|
| ALLOWED_UPLOAD_EXTENSIONS = {".pdf", ".docx", ".txt"} |
| HTML_EXTENSION = ".html" |
| PDF_EXTENSION = ".pdf" |
| LEGAL_SOURCE_TYPE_BY_LABEL = { |
| "hien phap": LegalSourceType.HIEN_PHAP, |
| "bo luat": LegalSourceType.BO_LUAT, |
| "luat": LegalSourceType.LUAT, |
| "phap lenh": LegalSourceType.PHAP_LENH, |
| "lenh": LegalSourceType.LENH, |
| "nghi quyet": LegalSourceType.NGHI_QUYET, |
| "nghi quyet lien tich": LegalSourceType.NGHI_QUYET_LIEN_TICH, |
| "nghi dinh": LegalSourceType.NGHI_DINH, |
| "quyet dinh": LegalSourceType.QUYET_DINH, |
| "chi thi": LegalSourceType.CHI_THI, |
| "thong tu": LegalSourceType.THONG_TU, |
| "thong tu lien tich": LegalSourceType.THONG_TU_LIEN_TICH, |
| "van ban hop nhat": LegalSourceType.VAN_BAN_HOP_NHAT, |
| "dieu uoc quoc te": LegalSourceType.DIEU_UOC_QUOC_TE, |
| "cong van": LegalSourceType.CONG_VAN, |
| "cong dien": LegalSourceType.CONG_DIEN, |
| "thong bao": LegalSourceType.THONG_BAO, |
| "quy dinh": LegalSourceType.QUY_DINH, |
| "quy che": LegalSourceType.QUY_CHE, |
| "dieu le": LegalSourceType.DIEU_LE, |
| "huong dan": LegalSourceType.HUONG_DAN, |
| "khac": LegalSourceType.OTHER, |
| } |
|
|
|
|
| class DocumentServiceError(Exception): |
| status_code = 400 |
| code = "validation_error" |
| message = "Document request is invalid." |
|
|
| def __init__(self, message: str | None = None, details: dict | None = None) -> None: |
| super().__init__(message or self.message) |
| self.message = message or self.message |
| self.details = details or {} |
|
|
|
|
| class EmptyFileError(DocumentServiceError): |
| status_code = 400 |
| code = "empty_file" |
| message = "Uploaded file is empty." |
|
|
|
|
| class FileTooLargeError(DocumentServiceError): |
| status_code = 413 |
| code = "file_too_large" |
| message = "Uploaded file exceeds the configured size limit." |
|
|
|
|
| class UnsupportedFileTypeError(DocumentServiceError): |
| status_code = 415 |
| code = "unsupported_file_type" |
| message = "Uploaded file type is not supported." |
|
|
|
|
| class DocumentStoreUnavailableError(DocumentServiceError): |
| status_code = 503 |
| code = "document_store_unavailable" |
| message = "Document store is unavailable." |
|
|
|
|
| class IngestionUnavailableError(DocumentServiceError): |
| status_code = 503 |
| code = "ingestion_unavailable" |
| message = "Document ingestion is unavailable." |
|
|
|
|
| class MissingExtractionDependencyError(DocumentServiceError): |
| status_code = 503 |
| code = "missing_extraction_dependency" |
| message = "Required document extraction dependency is unavailable." |
|
|
|
|
| class EmptyExtractionError(DocumentServiceError): |
| status_code = 422 |
| code = "empty_extraction" |
| message = "Document extraction returned empty text." |
|
|
|
|
| class DocumentNotFoundError(DocumentServiceError): |
| status_code = 404 |
| code = "not_found" |
| message = "Document was not found." |
|
|
|
|
| @dataclass(frozen=True) |
| class DocumentRecord: |
| document_id: str |
| filename: str |
| document_type: DocumentType |
| purpose: Purpose |
| retention_policy: RetentionPolicy |
| ingestion_status: IngestionStatus |
| metadata: DocumentMetadata |
| created_at: str |
|
|
|
|
| @dataclass(frozen=True) |
| class DocumentUploadInput: |
| filename: str |
| content: bytes |
| document_type: DocumentType |
| purpose: Purpose |
| retention_policy: RetentionPolicy |
| title: str | None |
| metadata: DocumentMetadata |
|
|
|
|
| @dataclass(frozen=True) |
| class ExtractionResult: |
| text: str |
| file_type: str |
| method: str |
| warnings: list[str] |
|
|
|
|
| @dataclass(frozen=True) |
| class DocumentIngestionResult: |
| upload_record: DocumentRecord |
| extraction: ExtractionResult |
| document: LegalDocument |
| chunks: list[LegalChunk] |
| citation_previews: list[str] |
| warnings: list[str] |
|
|
|
|
| @dataclass(frozen=True) |
| class DocumentListFilters: |
| page: int |
| page_size: int |
| document_type: DocumentType | None = None |
| purpose: Purpose | None = None |
| ingestion_status: IngestionStatus | None = None |
| effective_status: EffectiveStatus | None = None |
| q: str | None = None |
|
|
|
|
| @dataclass(frozen=True) |
| class ScopedUploadContext: |
| source_scope: SourceScope |
| session_id: str | None = None |
| owner_user_id: str | None = None |
| org_id: str | None = None |
| uploaded_by: str | None = None |
| authority_level: AuthorityLevel = AuthorityLevel.UPLOADED_CONTEXT |
| document_status: SourceDocumentStatus = SourceDocumentStatus.ACTIVE |
|
|
|
|
| def normalize_legal_source_label(value: str) -> str: |
| normalized = unicodedata.normalize("NFKD", value) |
| ascii_value = "".join(char for char in normalized if not unicodedata.combining(char)) |
| ascii_value = ascii_value.replace("đ", "d").replace("Đ", "D") |
| return " ".join(ascii_value.casefold().strip().replace("_", " ").split()) |
|
|
|
|
| def legal_source_type_label_vi(value: LegalSourceType) -> str: |
| return LEGAL_SOURCE_TYPE_LABELS_VI[value] |
|
|
|
|
| def legal_source_type_from_source_value(value: str) -> LegalSourceType: |
| slug_match = next((item for item in LegalSourceType if item.value == value), None) |
| if slug_match is not None: |
| return slug_match |
| return LEGAL_SOURCE_TYPE_BY_LABEL.get(normalize_legal_source_label(value), LegalSourceType.OTHER) |
|
|
|
|
| def parse_upload_metadata(metadata_json: str | None) -> dict: |
| if metadata_json in {None, ""}: |
| return {} |
| try: |
| parsed = json.loads(metadata_json) |
| except json.JSONDecodeError as exc: |
| raise DocumentServiceError( |
| message="Metadata must be a valid JSON object.", |
| details={"metadata": "invalid_json"}, |
| ) from exc |
| if not isinstance(parsed, dict): |
| raise DocumentServiceError( |
| message="Metadata must be a valid JSON object.", |
| details={"metadata": "expected_object"}, |
| ) |
| return parsed |
|
|
|
|
| def ensure_upload_directories(settings: Settings) -> None: |
| """Safely ensure configured upload directories exist. |
| Only creates directories if upload_storage_backend is local_disk. |
| Does not delete existing uploads. |
| """ |
| if settings.upload_storage_backend != "local_disk": |
| return |
| for path_str in [settings.upload_dir, settings.guest_upload_dir, settings.org_upload_dir]: |
| if path_str: |
| Path(path_str).mkdir(parents=True, exist_ok=True) |
|
|
|
|
| def is_safe_upload_path(path: Path | str, settings: Settings) -> bool: |
| """Check if the resolved path is inside the allowed upload directories to prevent path traversal.""" |
| try: |
| resolved_path = Path(path).resolve() |
| allowed_bases = [ |
| Path(settings.upload_dir).resolve(), |
| Path(settings.guest_upload_dir).resolve(), |
| Path(settings.org_upload_dir).resolve(), |
| ] |
| if settings.app_env == "test": |
| import tempfile |
| allowed_bases.append(Path(tempfile.gettempdir()).resolve()) |
| return any( |
| resolved_path == base or base in resolved_path.parents |
| for base in allowed_bases |
| if base |
| ) |
| except Exception: |
| return False |
|
|
|
|
| def build_document_metadata(raw_metadata: dict, title: str | None) -> DocumentMetadata: |
| metadata = dict(raw_metadata) |
| if title is not None: |
| metadata["title"] = title |
|
|
| raw_loai_van_ban = metadata.pop("loai_van_ban", None) or metadata.get("raw_loai_van_ban") |
| if raw_loai_van_ban is not None: |
| metadata["raw_loai_van_ban"] = raw_loai_van_ban |
| metadata["legal_source_type"] = legal_source_type_from_source_value(str(raw_loai_van_ban)) |
|
|
| try: |
| document_metadata = DocumentMetadata.model_validate(metadata) |
| except ValidationError as exc: |
| raise DocumentServiceError( |
| message="Document metadata validation failed.", |
| details={"errors": exc.errors(include_input=False, include_url=False)}, |
| ) from exc |
|
|
| if document_metadata.legal_source_type is not None: |
| document_metadata.legal_source_type_label_vi = legal_source_type_label_vi( |
| document_metadata.legal_source_type |
| ) |
| return document_metadata |
|
|
|
|
| def extract_document_text_from_path(path: Path | str) -> ExtractionResult: |
| source_path = Path(path) |
| suffix = source_path.suffix.casefold() |
| if suffix == PDF_EXTENSION: |
| return _extract_pdf_text_from_path(source_path) |
| if suffix == ".txt": |
| return _extract_plain_text_from_path(source_path) |
| if suffix == ".docx": |
| return _extract_docx_text_from_path(source_path) |
| raise UnsupportedFileTypeError(message=f"No extraction adapter is available for {suffix!r}.") |
|
|
|
|
| def extract_document_text_from_bytes(content: bytes, *, filename: str) -> ExtractionResult: |
| suffix = PurePath(filename).suffix.casefold() |
| if suffix == PDF_EXTENSION: |
| return _extract_pdf_text_from_bytes(content) |
| if suffix == ".txt": |
| return _extract_plain_text_from_bytes(content, file_type="txt") |
| if suffix == ".docx": |
| return _extract_docx_text_from_bytes(content) |
| raise UnsupportedFileTypeError(message=f"No extraction adapter is available for {suffix!r}.") |
|
|
|
|
| def ingest_upload_to_legal_chunks( |
| *, |
| upload: DocumentUploadInput, |
| max_upload_bytes: int, |
| ingestion_service: "InMemoryDocumentIngestionService | None" = None, |
| source_uri: str | None = None, |
| chunker: LegalChunker | None = None, |
| citation_service: CitationService | None = None, |
| include_citation_previews: bool = True, |
| fail_on_empty: bool = False, |
| ) -> DocumentIngestionResult: |
| service = ingestion_service or InMemoryDocumentIngestionService() |
| upload_record = service.upload_document(upload, max_upload_bytes=max_upload_bytes) |
| extraction = extract_document_text_from_bytes(upload.content, filename=upload.filename) |
| text = extraction.text.strip() |
|
|
| document = _legal_document_from_upload( |
| upload_record=upload_record, |
| upload=upload, |
| extraction=extraction, |
| source_uri=source_uri or upload_record.filename, |
| ready=bool(text), |
| ) |
|
|
| if not text: |
| warnings = [*extraction.warnings, "Skipped chunking because extraction returned empty text."] |
| if fail_on_empty: |
| raise EmptyExtractionError(details={"warnings": warnings}) |
| return DocumentIngestionResult( |
| upload_record=upload_record, |
| extraction=extraction, |
| document=document, |
| chunks=[], |
| citation_previews=[], |
| warnings=warnings, |
| ) |
|
|
| chunks = (chunker or LegalChunker()).chunk_text( |
| document_id=document.document_id, |
| text=extraction.text, |
| ) |
| previews = [] |
| if include_citation_previews: |
| service_for_citations = citation_service or CitationService() |
| previews = [ |
| service_for_citations.build_label(document=document, chunk=chunk) for chunk in chunks |
| ] |
| return DocumentIngestionResult( |
| upload_record=upload_record, |
| extraction=extraction, |
| document=document, |
| chunks=chunks, |
| citation_previews=previews, |
| warnings=list(extraction.warnings), |
| ) |
|
|
|
|
| def _legal_document_from_upload( |
| *, |
| upload_record: DocumentRecord, |
| upload: DocumentUploadInput, |
| extraction: ExtractionResult, |
| source_uri: str, |
| ready: bool, |
| ) -> LegalDocument: |
| metadata = upload.metadata |
| parse_flags = [] |
| if not any( |
| [ |
| metadata.document_number, |
| metadata.legal_source_type, |
| metadata.authority, |
| metadata.effective_date, |
| ] |
| ): |
| parse_flags.append(LegalParseFlag.METADATA_MISSING) |
|
|
| return LegalDocument( |
| document_id=upload_record.document_id, |
| title=metadata.title or upload.title or upload_record.filename, |
| document_type=upload_record.document_type, |
| source_uri=source_uri, |
| ingestion_status=IngestionStatus.READY if ready else IngestionStatus.FAILED, |
| ingested_at=datetime.now(UTC), |
| effective_status=metadata.effective_status or EffectiveStatus.UNKNOWN, |
| document_number=metadata.document_number, |
| legal_source_type=metadata.legal_source_type, |
| authority=metadata.authority, |
| metadata={ |
| "filename": upload_record.filename, |
| "file_size_bytes": len(upload.content), |
| "file_type": extraction.file_type, |
| "extraction_method": extraction.method, |
| "upload_ingestion_status": upload_record.ingestion_status.value, |
| "metadata_source": "provided-or-metadata-missing", |
| }, |
| parse_flags=parse_flags, |
| ) |
|
|
|
|
| def _extract_plain_text_from_path(path: Path) -> ExtractionResult: |
| try: |
| text = path.read_text(encoding="utf-8-sig") |
| method = "Path.read_text(utf-8-sig)" |
| except UnicodeDecodeError: |
| text = path.read_text(encoding="utf-8") |
| method = "Path.read_text(utf-8)" |
| return ExtractionResult( |
| text=_normalize_text_newlines(text), |
| file_type=path.suffix.casefold().lstrip("."), |
| method=method, |
| warnings=[] if text.strip() else ["Text extraction returned empty text."], |
| ) |
|
|
|
|
| def _extract_plain_text_from_bytes(content: bytes, *, file_type: str) -> ExtractionResult: |
| try: |
| text = content.decode("utf-8-sig") |
| method = "bytes.decode(utf-8-sig)" |
| except UnicodeDecodeError: |
| text = content.decode("utf-8") |
| method = "bytes.decode(utf-8)" |
| return ExtractionResult( |
| text=_normalize_text_newlines(text), |
| file_type=file_type, |
| method=method, |
| warnings=[] if text.strip() else ["Text extraction returned empty text."], |
| ) |
|
|
|
|
| def _extract_pdf_text_from_path(path: Path) -> ExtractionResult: |
| data = path.read_bytes() |
| return _extract_pdf_text(data, source=str(path)) |
|
|
|
|
| def _extract_pdf_text_from_bytes(content: bytes) -> ExtractionResult: |
| return _extract_pdf_text(content, source=None) |
|
|
|
|
| def _extract_pdf_text(content: bytes, *, source: str | None) -> ExtractionResult: |
| pypdf = _optional_import("pypdf") |
| if pypdf is not None: |
| reader_input = source if source is not None else BytesIO(content) |
| reader = pypdf.PdfReader(reader_input) |
| pages = [(page.extract_text() or "") for page in reader.pages] |
| text = "\n\n".join(page.strip() for page in pages if page.strip()) |
| return ExtractionResult( |
| text=text, |
| file_type="pdf", |
| method="pypdf.PdfReader", |
| warnings=[] if text else ["PDF extraction returned empty text."], |
| ) |
|
|
| pypdf2 = _optional_import("PyPDF2") |
| if pypdf2 is not None: |
| reader_input = source if source is not None else BytesIO(content) |
| reader = pypdf2.PdfReader(reader_input) |
| pages = [(page.extract_text() or "") for page in reader.pages] |
| text = "\n\n".join(page.strip() for page in pages if page.strip()) |
| return ExtractionResult( |
| text=text, |
| file_type="pdf", |
| method="PyPDF2.PdfReader", |
| warnings=[] if text else ["PDF extraction returned empty text."], |
| ) |
|
|
| fitz = _optional_import("fitz") |
| if fitz is None: |
| raise MissingExtractionDependencyError( |
| message=( |
| "PDF extraction dependency is missing. Install one parser, for example: " |
| "python -m pip install pypdf" |
| ) |
| ) |
|
|
| if source is not None: |
| document = fitz.open(source) |
| else: |
| document = fitz.open(stream=content, filetype="pdf") |
| with document: |
| pages = [page.get_text("text") or "" for page in document] |
| text = "\n\n".join(page.strip() for page in pages if page.strip()) |
| return ExtractionResult( |
| text=text, |
| file_type="pdf", |
| method="PyMuPDF(fitz)", |
| warnings=[] if text else ["PDF extraction returned empty text."], |
| ) |
|
|
|
|
| def _extract_docx_text_from_path(path: Path) -> ExtractionResult: |
| data = path.read_bytes() |
| docx = _optional_import("docx") |
| if docx is not None: |
| document = docx.Document(str(path)) |
| return _python_docx_result(document) |
| return _extract_docx_text_with_stdlib(data) |
|
|
|
|
| def _extract_docx_text_from_bytes(content: bytes) -> ExtractionResult: |
| docx = _optional_import("docx") |
| if docx is not None: |
| document = docx.Document(BytesIO(content)) |
| return _python_docx_result(document) |
| return _extract_docx_text_with_stdlib(content) |
|
|
|
|
| def _python_docx_result(document) -> ExtractionResult: |
| text = "\n".join(paragraph.text for paragraph in document.paragraphs).strip() |
| return ExtractionResult( |
| text=text, |
| file_type="docx", |
| method="python-docx", |
| warnings=[] if text else ["DOCX extraction returned empty text."], |
| ) |
|
|
|
|
| def _extract_docx_text_with_stdlib(content: bytes) -> ExtractionResult: |
| try: |
| with zipfile.ZipFile(BytesIO(content)) as archive: |
| document_xml = archive.read("word/document.xml") |
| except (KeyError, zipfile.BadZipFile) as exc: |
| raise MissingExtractionDependencyError( |
| message=( |
| "DOCX extraction failed because the file is not a readable DOCX package. " |
| "Install python-docx for broader DOCX compatibility: " |
| "python -m pip install python-docx" |
| ) |
| ) from exc |
|
|
| root = ElementTree.fromstring(document_xml) |
| namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} |
| paragraphs = [] |
| for paragraph in root.findall(".//w:p", namespace): |
| pieces = [ |
| node.text or "" |
| for node in paragraph.findall(".//w:t", namespace) |
| ] |
| text = "".join(pieces).strip() |
| if text: |
| paragraphs.append(text) |
| text = _normalize_text_newlines("\n".join(paragraphs)) |
| return ExtractionResult( |
| text=text, |
| file_type="docx", |
| method="stdlib zipfile+xml", |
| warnings=[] if text else ["DOCX extraction returned empty text."], |
| ) |
|
|
|
|
| def _optional_import(module_name: str): |
| try: |
| return importlib.import_module(module_name) |
| except ImportError: |
| return None |
|
|
|
|
| def _normalize_text_newlines(text: str) -> str: |
| return text.replace("\r\n", "\n").replace("\r", "\n") |
|
|
|
|
| class InMemoryDocumentIngestionService: |
| def __init__(self) -> None: |
| self._documents: list[DocumentRecord] = [] |
|
|
| def reset(self) -> None: |
| self._documents.clear() |
|
|
| def upload_document( |
| self, |
| upload: DocumentUploadInput, |
| max_upload_bytes: int, |
| ) -> DocumentRecord: |
| self._validate_upload(upload, max_upload_bytes) |
| record = DocumentRecord( |
| document_id=f"doc_{uuid4().hex}", |
| filename=PurePath(upload.filename).name, |
| document_type=upload.document_type, |
| purpose=upload.purpose, |
| retention_policy=upload.retention_policy, |
| ingestion_status=IngestionStatus.QUEUED, |
| metadata=upload.metadata, |
| created_at=utc_now_iso(), |
| ) |
| self._documents.append(record) |
| return record |
|
|
| def list_documents(self, filters: DocumentListFilters) -> tuple[list[DocumentListItem], int]: |
| filtered = list(self._documents) |
| if filters.document_type is not None: |
| filtered = [item for item in filtered if item.document_type == filters.document_type] |
| if filters.purpose is not None: |
| filtered = [item for item in filtered if item.purpose == filters.purpose] |
| if filters.ingestion_status is not None: |
| filtered = [ |
| item for item in filtered if item.ingestion_status == filters.ingestion_status |
| ] |
| if filters.effective_status is not None: |
| filtered = [ |
| item |
| for item in filtered |
| if item.metadata.effective_status == filters.effective_status |
| ] |
| if filters.q: |
| query = filters.q.casefold() |
| filtered = [ |
| item |
| for item in filtered |
| if query in (item.metadata.title or "").casefold() |
| or query in item.filename.casefold() |
| or query in (item.metadata.document_number or "").casefold() |
| ] |
|
|
| total = len(filtered) |
| start = (filters.page - 1) * filters.page_size |
| end = start + filters.page_size |
| return [self._to_list_item(item) for item in filtered[start:end]], total |
|
|
| def _validate_upload(self, upload: DocumentUploadInput, max_upload_bytes: int) -> None: |
| filename = PurePath(upload.filename).name |
| extension = PurePath(filename).suffix.casefold() |
| if not upload.content: |
| raise EmptyFileError() |
| if len(upload.content) > max_upload_bytes: |
| raise FileTooLargeError(details={"max_upload_bytes": max_upload_bytes}) |
| if extension in ALLOWED_UPLOAD_EXTENSIONS: |
| return |
| if extension == HTML_EXTENSION: |
| approved_import = bool(upload.metadata.model_extra.get("approved_corpus_import")) |
| if upload.purpose == Purpose.CORPUS and approved_import: |
| return |
| raise UnsupportedFileTypeError() |
|
|
| @staticmethod |
| def _to_list_item(record: DocumentRecord) -> DocumentListItem: |
| return DocumentListItem( |
| document_id=record.document_id, |
| title=record.metadata.title or record.filename, |
| filename=record.filename, |
| document_type=record.document_type, |
| purpose=record.purpose, |
| retention_policy=record.retention_policy, |
| ingestion_status=record.ingestion_status, |
| effective_status=record.metadata.effective_status, |
| created_at=record.created_at, |
| ) |
|
|
|
|
| document_ingestion_service = InMemoryDocumentIngestionService() |
|
|
|
|
| PRIVATE_COLLECTION_BY_SCOPE = { |
| SourceScope.GUEST_SESSION: "guest_sessions", |
| SourceScope.ORG_PRIVATE: "org_documents", |
| } |
|
|
|
|
| def _private_collection_for_scope(scope: SourceScope) -> str: |
| try: |
| if scope == SourceScope.ORG_PRIVATE: |
| from src.config import get_settings |
| return get_settings().private_chroma_collection_name |
| return PRIVATE_COLLECTION_BY_SCOPE[scope] |
| except KeyError as exc: |
| raise DocumentServiceError( |
| "Unsupported private document scope. Personal/user_private libraries are disabled.", |
| details={"source_scope": scope.value}, |
| ) from exc |
|
|
|
|
| class DocumentLibraryService: |
| def __init__( |
| self, |
| db: Session, |
| settings: Settings | None = None, |
| ) -> None: |
| self.db = db |
| self.settings = settings or get_settings() |
|
|
| def ensure_guest_session_id(self, session_id: str | None) -> str: |
| cleaned = str(session_id or "").strip() |
| if cleaned: |
| return cleaned |
| return f"guest_{token_urlsafe(32)}" |
|
|
| def upload_scoped_document( |
| self, |
| upload: DocumentUploadInput, |
| *, |
| context: ScopedUploadContext, |
| mime_type: str = "", |
| ) -> tuple[DbDocumentRecord, int, list[str]]: |
| if not self.settings.enable_private_document_library: |
| raise DocumentStoreUnavailableError("Private document libraries are disabled.") |
|
|
| storage_path = self._write_upload_file(upload, context) |
| source_uri = f"private://documents/{context.source_scope.value}/{Path(storage_path).name}" |
| ingestion = ingest_upload_to_legal_chunks( |
| upload=upload, |
| max_upload_bytes=self.settings.max_upload_bytes, |
| source_uri=source_uri, |
| fail_on_empty=True, |
| ) |
| self._apply_private_metadata( |
| ingestion.document, |
| ingestion.chunks, |
| context=context, |
| filename=ingestion.upload_record.filename, |
| mime_type=mime_type, |
| ) |
| collection_name = _private_collection_for_scope(context.source_scope) |
|
|
| |
| record = DbDocumentRecord( |
| id=ingestion.document.document_id, |
| source_scope=context.source_scope.value, |
| owner_user_id=context.owner_user_id, |
| org_id=context.org_id, |
| session_id=context.session_id, |
| uploaded_by=context.uploaded_by, |
| title=ingestion.document.title, |
| filename=ingestion.upload_record.filename, |
| mime_type=mime_type, |
| storage_path=storage_path, |
| source_uri=source_uri, |
| authority_level=context.authority_level.value, |
| document_status=SourceDocumentStatus.PENDING_INDEX.value, |
| document_type=upload.document_type.value, |
| purpose=upload.purpose.value, |
| retention_policy=upload.retention_policy.value, |
| effective_date=upload.metadata.effective_date, |
| ) |
| self.db.add(record) |
| for chunk in ingestion.chunks: |
| self.db.add( |
| DocumentChunkRecord( |
| id=new_id("dchk"), |
| document_id=record.id, |
| chunk_id=chunk.chunk_id, |
| chunk_index=chunk.chunk_index, |
| chroma_collection=collection_name, |
| chroma_id=chunk.chunk_id, |
| token_count=chunk.token_count, |
| chunk_text=chunk.content, |
| metadata_json=chunk.metadata, |
| citation_label=None, |
| citation_debug=None, |
| content_hash=hashlib.sha256(chunk.content.encode("utf-8")).hexdigest(), |
| ) |
| ) |
| self.db.commit() |
| self.db.refresh(record) |
|
|
| |
| from src.services.retrieval_service_client import RetrievalServiceClient |
| chunks_payload = [ |
| { |
| "chunk_id": chunk.chunk_id, |
| "chunk_index": chunk.chunk_index, |
| "text": chunk.content, |
| "metadata": chunk.metadata, |
| } |
| for chunk in ingestion.chunks |
| ] |
| client = RetrievalServiceClient() |
| success = client.index_chunks( |
| document_id=record.id, |
| organization_id=context.org_id or "", |
| collection_name=collection_name, |
| chunks=chunks_payload, |
| ) |
|
|
| if success: |
| record.document_status = SourceDocumentStatus.ACTIVE.value |
| for chunk_rec in record.chunks: |
| meta = dict(chunk_rec.metadata_json) if chunk_rec.metadata_json else {} |
| meta["document_status"] = SourceDocumentStatus.ACTIVE.value |
| chunk_rec.metadata_json = meta |
| else: |
| record.document_status = SourceDocumentStatus.INDEXING_FAILED.value |
| for chunk_rec in record.chunks: |
| meta = dict(chunk_rec.metadata_json) if chunk_rec.metadata_json else {} |
| meta["document_status"] = SourceDocumentStatus.INDEXING_FAILED.value |
| chunk_rec.metadata_json = meta |
|
|
| self.db.add(record) |
| self.db.commit() |
| self.db.refresh(record) |
|
|
| return record, len(ingestion.chunks), ingestion.warnings |
|
|
| def list_documents(self, context: ScopedUploadContext) -> list[DbDocumentRecord]: |
| statement = self._scoped_statement(context).order_by(DbDocumentRecord.created_at.desc()) |
| return list(self.db.scalars(statement).all()) |
|
|
| def get_document(self, document_id: str, context: ScopedUploadContext) -> DbDocumentRecord: |
| record = self.db.scalar( |
| self._scoped_statement(context).where(DbDocumentRecord.id == document_id) |
| ) |
| if record is None: |
| raise DocumentNotFoundError() |
| return record |
|
|
| def delete_document(self, document_id: str, context: ScopedUploadContext) -> DbDocumentRecord: |
| record = self.get_document(document_id, context) |
| record.deleted_at = utc_now() |
| record.document_status = SourceDocumentStatus.OUTDATED.value |
| self.db.add(record) |
| self._delete_vectors(record) |
|
|
| |
| if record.storage_path: |
| try: |
| if is_safe_upload_path(record.storage_path, self.settings): |
| file_path = Path(record.storage_path).resolve() |
| if file_path.is_file(): |
| file_path.unlink() |
| else: |
| import logging |
| logging.getLogger(__name__).warning( |
| "Skipped physical file deletion for document %s: path %s is outside allowed upload directories.", |
| record.id, |
| record.storage_path, |
| ) |
| except Exception as exc: |
| import logging |
| logging.getLogger(__name__).warning( |
| "Best-effort physical file deletion failed for document %s (path=%s): %s", |
| record.id, |
| record.storage_path, |
| exc, |
| ) |
|
|
| self.db.commit() |
| self.db.refresh(record) |
| return record |
|
|
| @staticmethod |
| def to_item(record: DbDocumentRecord) -> ScopedDocumentItem: |
| return ScopedDocumentItem( |
| document_id=record.id, |
| source_scope=SourceScope(record.source_scope), |
| title=record.title, |
| filename=record.filename, |
| mime_type=record.mime_type, |
| authority_level=AuthorityLevel(record.authority_level), |
| document_status=SourceDocumentStatus(record.document_status), |
| document_type=DocumentType(record.document_type), |
| purpose=Purpose(record.purpose), |
| retention_policy=RetentionPolicy(record.retention_policy), |
| created_at=record.created_at.isoformat(), |
| updated_at=record.updated_at.isoformat(), |
| effective_date=record.effective_date, |
| ) |
|
|
| def _scoped_statement(self, context: ScopedUploadContext): |
| statement = select(DbDocumentRecord).where( |
| DbDocumentRecord.source_scope == context.source_scope.value, |
| DbDocumentRecord.deleted_at.is_(None), |
| ) |
| if context.source_scope == SourceScope.GUEST_SESSION: |
| statement = statement.where(DbDocumentRecord.session_id == context.session_id) |
| elif context.source_scope == SourceScope.USER_PRIVATE: |
| statement = statement.where(DbDocumentRecord.owner_user_id == context.owner_user_id) |
| elif context.source_scope == SourceScope.ORG_PRIVATE: |
| statement = statement.where(DbDocumentRecord.org_id == context.org_id) |
| return statement |
|
|
| def _write_upload_file(self, upload: DocumentUploadInput, context: ScopedUploadContext) -> str: |
| directory = self._upload_directory(context) |
| directory.mkdir(parents=True, exist_ok=True) |
| safe_name = PurePath(upload.filename).name or "upload.bin" |
| path = directory / f"{uuid4().hex}_{safe_name}" |
| path.write_bytes(upload.content) |
| return str(path) |
|
|
| def _upload_directory(self, context: ScopedUploadContext) -> Path: |
| if context.source_scope == SourceScope.GUEST_SESSION: |
| return Path(self.settings.guest_upload_dir) / str(context.session_id) |
| if context.source_scope == SourceScope.ORG_PRIVATE: |
| return Path(self.settings.org_upload_dir) / str(context.org_id) |
| raise DocumentServiceError("Unsupported document scope.") |
|
|
| def _apply_private_metadata( |
| self, |
| document: LegalDocument, |
| chunks: list[LegalChunk], |
| *, |
| context: ScopedUploadContext, |
| filename: str, |
| mime_type: str, |
| ) -> None: |
| org_id = context.org_id or "" |
| document_id = document.document_id |
| source_uri = document.source_uri or f"org://{org_id}/documents/{document_id}" |
|
|
| metadata = { |
| "source_scope": context.source_scope.value, |
| "owner_user_id": context.owner_user_id or "", |
| "org_id": org_id, |
| "organization_id": org_id, |
| "session_id": context.session_id or "", |
| "uploaded_by": context.uploaded_by or "", |
| "filename": filename, |
| "mime_type": mime_type, |
| "authority_level": context.authority_level.value, |
| "document_status": context.document_status.value, |
| "document_id": document_id, |
| "source_uri": source_uri, |
| } |
| document.metadata.update(metadata) |
| if not document.source_uri: |
| document.source_uri = source_uri |
|
|
| for chunk in chunks: |
| chunk.metadata.update(metadata) |
| chunk.metadata["chunk_id"] = chunk.chunk_id |
| chunk.metadata["chunk_index"] = chunk.chunk_index |
|
|
| def _index_private_chunks( |
| self, |
| *, |
| document: LegalDocument, |
| chunks: list[LegalChunk], |
| collection_name: str, |
| ) -> None: |
| from src.services.corpus_indexing import ChromaIndexAdapter, build_indexable_records |
|
|
| explicit_provider = self.settings.embedding_provider |
| allow_fake_embeddings = ( |
| explicit_provider == "auto" |
| and not ( |
| has_real_api_key(self.settings.openai_api_key) |
| or has_real_api_key(self.settings.google_api_key) |
| ) |
| ) |
|
|
| adapter = ChromaIndexAdapter( |
| persist_dir=self.settings.private_chroma_persist_dir, |
| collection_name=collection_name, |
| embedding_provider=explicit_provider, |
| allow_fake_embeddings=allow_fake_embeddings, |
| ) |
| try: |
| adapter.upsert(build_indexable_records(document, chunks)) |
| except Exception as exc: |
| raise DocumentStoreUnavailableError( |
| "Could not index uploaded document in Chroma. " |
| "Check EMBEDDING_PROVIDER and rebuild the private Chroma collection if needed.", |
| details={"collection_name": collection_name, "reason": str(exc)}, |
| ) from exc |
|
|
| def _delete_vectors(self, record: DbDocumentRecord) -> None: |
| collection_name = _private_collection_for_scope(SourceScope(record.source_scope)) |
| try: |
| from src.services.retrieval_service_client import RetrievalServiceClient |
| client = RetrievalServiceClient() |
| client.delete_document(record.id, collection_name) |
| except Exception: |
| return |
|
|