Download app/extractor.py from deeep-sec/ai-content-detector: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/deeep-sec/ai-content-detector/resolve/main/app/extractor.py
- Command line
-
hf download hf://deeep-sec/ai-content-detector/app/extractor.py
-
curl -L -o extractor.py https://huggingface.co/deeep-sec/ai-content-detector/resolve/main/app/extractor.py
13.7 kB
| """ | |
| Multi-format document ingestion engine conforming to VeritasDoc Engineering Specification | |
| (Phase 1, Sections 3.1 & 3.2). | |
| Accepts 7 document formats: .pdf, .docx, .doc, .pptx, .ppt, .rtf, .txt. | |
| Extracts clean structural hierarchical trees and computes SHA-256 cryptographic checksums. | |
| """ | |
| import logging | |
| import re | |
| from pathlib import Path | |
| from typing import Any | |
| from app.config import ALLOWED_EXTENSIONS, MIN_DOCUMENT_WORDS | |
| from app.sanitizer import compute_sha256, sanitize_text, tokenize_sentences_robust | |
| logger = logging.getLogger("ai_detector.extractor") | |
| class DocumentExtractionError(Exception): | |
| """Exception raised when document parsing fails or validation does not pass.""" | |
| def __init__(self, message: str, status_code: int = 400): | |
| super().__init__(message) | |
| self.message = message | |
| self.status_code = status_code | |
| def extract_txt(file_path: Path) -> str: | |
| """Extract text from a plain text file, auto-detecting common encodings.""" | |
| encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252"] | |
| data = file_path.read_bytes() | |
| for enc in encodings: | |
| try: | |
| return data.decode(enc) | |
| except UnicodeDecodeError: | |
| continue | |
| return data.decode("utf-8", errors="replace") | |
| def extract_docx_sections(file_path: Path) -> list[dict[str, Any]]: | |
| """ | |
| Extract structured sections from Word .docx preserving headings and table cells. | |
| """ | |
| try: | |
| import docx | |
| except ImportError as e: | |
| raise DocumentExtractionError("DOCX parser dependency missing.", status_code=500) from e | |
| try: | |
| doc = docx.Document(str(file_path)) | |
| sections: list[dict[str, Any]] = [] | |
| current_title = "Document Overview" | |
| current_paras: list[str] = [] | |
| def flush_section(): | |
| nonlocal current_title, current_paras | |
| if current_paras: | |
| full_text = "\n\n".join(current_paras).strip() | |
| if full_text: | |
| sections.append({ | |
| "title": current_title, | |
| "raw_text": full_text | |
| }) | |
| current_paras = [] | |
| for p in doc.paragraphs: | |
| text = p.text.strip() | |
| if not text: | |
| continue | |
| # Detect Heading styles | |
| if p.style and p.style.name and p.style.name.lower().startswith("heading"): | |
| flush_section() | |
| current_title = text | |
| else: | |
| current_paras.append(text) | |
| # Extract tables | |
| for table in doc.tables: | |
| table_lines = [] | |
| for row in table.rows: | |
| cells = [cell.text.strip() for cell in row.cells if cell.text.strip()] | |
| if cells: | |
| table_lines.append(" | ".join(cells)) | |
| if table_lines: | |
| current_paras.append("\n".join(table_lines)) | |
| flush_section() | |
| if not sections and current_paras: | |
| sections.append({ | |
| "title": "Document Overview", | |
| "raw_text": "\n\n".join(current_paras) | |
| }) | |
| return sections | |
| except Exception as e: | |
| logger.error("Failed to parse DOCX file: %s", e) | |
| raise DocumentExtractionError(f"Corrupt or unreadable DOCX file: {e}", status_code=400) from e | |
| def extract_pdf_sections(file_path: Path) -> list[dict[str, Any]]: | |
| """ | |
| Extract structured pages/sections from PDF using pdfplumber with pypdf fallback. | |
| Preserves multi-column flow and isolates running headers/footers. | |
| """ | |
| sections: list[dict[str, Any]] = [] | |
| # Attempt pdfplumber first | |
| try: | |
| import pdfplumber | |
| with pdfplumber.open(str(file_path)) as pdf: | |
| for page_idx, page in enumerate(pdf.pages): | |
| text = page.extract_text() | |
| if text and text.strip(): | |
| sections.append({ | |
| "title": f"Page {page_idx + 1}", | |
| "raw_text": text.strip() | |
| }) | |
| if sections: | |
| return sections | |
| except Exception as plumber_err: | |
| logger.info("pdfplumber parsing skipped or failed, falling back to pypdf: %s", plumber_err) | |
| # Fallback to pypdf | |
| try: | |
| import pypdf | |
| reader = pypdf.PdfReader(str(file_path)) | |
| for idx, page in enumerate(reader.pages): | |
| page_text = page.extract_text() | |
| if page_text and page_text.strip(): | |
| sections.append({ | |
| "title": f"Page {idx + 1}", | |
| "raw_text": page_text.strip() | |
| }) | |
| return sections | |
| except Exception as e: | |
| logger.error("Failed to parse PDF file: %s", e) | |
| raise DocumentExtractionError(f"Corrupt or unreadable PDF file: {e}", status_code=400) from e | |
| def extract_pptx_sections(file_path: Path) -> list[dict[str, Any]]: | |
| """ | |
| Extract text from PowerPoint .pptx presentation slide-by-slide. | |
| Parses slide title and body content separately. | |
| """ | |
| try: | |
| from pptx import Presentation | |
| except ImportError as e: | |
| raise DocumentExtractionError("PPTX parser dependency missing.", status_code=500) from e | |
| try: | |
| prs = Presentation(str(file_path)) | |
| sections: list[dict[str, Any]] = [] | |
| for slide_idx, slide in enumerate(prs.slides): | |
| slide_title = f"Slide {slide_idx + 1}" | |
| slide_content: list[str] = [] | |
| for shape in slide.shapes: | |
| if not shape.has_text_frame: | |
| continue | |
| tf = shape.text_frame | |
| text = tf.text.strip() | |
| if not text: | |
| continue | |
| if shape == slide.shapes.title: | |
| slide_title = f"Slide {slide_idx + 1}: {text}" | |
| else: | |
| for para in tf.paragraphs: | |
| p_text = para.text.strip() | |
| if p_text: | |
| slide_content.append(p_text) | |
| if slide_content: | |
| sections.append({ | |
| "title": slide_title, | |
| "raw_text": "\n".join(slide_content) | |
| }) | |
| return sections | |
| except Exception as e: | |
| logger.error("Failed to parse PPTX file: %s", e) | |
| raise DocumentExtractionError(f"Corrupt or unreadable PPTX file: {e}", status_code=400) from e | |
| def extract_rtf_text(file_path: Path) -> str: | |
| """Extract plain text from an RTF document using striprtf.""" | |
| try: | |
| from striprtf.striprtf import rtf_to_text | |
| except ImportError as e: | |
| raise DocumentExtractionError("RTF parser dependency missing.", status_code=500) from e | |
| try: | |
| raw_bytes = file_path.read_bytes() | |
| try: | |
| content = raw_bytes.decode("utf-8") | |
| except UnicodeDecodeError: | |
| content = raw_bytes.decode("latin-1", errors="ignore") | |
| return rtf_to_text(content) | |
| except Exception as e: | |
| logger.error("Failed to parse RTF file: %s", e) | |
| raise DocumentExtractionError(f"Corrupt or unreadable RTF file: {e}", status_code=400) from e | |
| def _extract_binary_text(raw_bytes: bytes) -> str: | |
| """Extract human-readable ASCII/UTF-8 strings from binary streams.""" | |
| pattern = re.compile(rb'[\x20-\x7E\t\r\n]{5,}') | |
| matches = pattern.findall(raw_bytes) | |
| decoded = [] | |
| for m in matches: | |
| try: | |
| s = m.decode('latin-1').strip() | |
| if len(s.split()) >= 2: | |
| decoded.append(s) | |
| except Exception: | |
| continue | |
| return "\n\n".join(decoded) | |
| def extract_legacy_ole_text(file_path: Path, format_type: str) -> str: | |
| """Extract text from legacy binary Word (.doc) or PowerPoint (.ppt).""" | |
| try: | |
| import olefile | |
| if olefile.isOleFile(str(file_path)): | |
| with olefile.OleFileIO(str(file_path)) as ole: | |
| target_stream = 'WordDocument' if format_type == 'doc' else 'PowerPoint Document' | |
| if ole.exists(target_stream): | |
| data = ole.openstream(target_stream).read() | |
| extracted = _extract_binary_text(data) | |
| if len(extracted.split()) >= MIN_DOCUMENT_WORDS: | |
| return extracted | |
| all_data = [] | |
| for entry in ole.listdir(): | |
| try: | |
| all_data.append(ole.openstream(entry).read()) | |
| except Exception: | |
| pass | |
| extracted = _extract_binary_text(b" ".join(all_data)) | |
| if len(extracted.split()) >= MIN_DOCUMENT_WORDS: | |
| return extracted | |
| except Exception as e: | |
| logger.warning("OLE parsing error for .%s, falling back to binary scan: %s", format_type, e) | |
| raw_data = file_path.read_bytes() | |
| extracted = _extract_binary_text(raw_data) | |
| if not extracted.strip(): | |
| raise DocumentExtractionError(f"Could not extract readable text from .{format_type} file.", status_code=400) | |
| return extracted | |
| def build_hierarchical_document_tree(file_path: Path | str, original_filename: str) -> dict[str, Any]: | |
| """ | |
| Ingest document and build the Hierarchical Document Tree according to Section 3.1: | |
| { | |
| "document_name": "filename.docx", | |
| "sha256_hash": "a8f5b1284cd92e07...", | |
| "total_words": 2450, | |
| "sections": [ | |
| { | |
| "section_index": 0, | |
| "title": "1. Introduction & Background", | |
| "sentences": [ | |
| { "id": 0, "text": "...", "start_idx": 0, "end_idx": 54 } | |
| ] | |
| } | |
| ] | |
| } | |
| """ | |
| path = Path(file_path) | |
| ext = Path(original_filename).suffix.lower() | |
| if ext not in ALLOWED_EXTENSIONS: | |
| allowed_list = ", ".join(sorted(ALLOWED_EXTENSIONS)) | |
| raise DocumentExtractionError( | |
| f"Unsupported file format '{ext}'. Accepted formats are: {allowed_list}", | |
| status_code=400, | |
| ) | |
| if not path.is_file(): | |
| raise DocumentExtractionError("File was not found on server storage.", status_code=404) | |
| raw_bytes = path.read_bytes() | |
| sha256_hash = compute_sha256(raw_bytes) | |
| raw_sections: list[dict[str, Any]] = [] | |
| if ext == ".docx": | |
| raw_sections = extract_docx_sections(path) | |
| elif ext == ".pdf": | |
| raw_sections = extract_pdf_sections(path) | |
| elif ext == ".pptx": | |
| raw_sections = extract_pptx_sections(path) | |
| elif ext == ".rtf": | |
| text = extract_rtf_text(path) | |
| raw_sections = [{"title": "Document Content", "raw_text": text}] | |
| elif ext == ".doc": | |
| text = extract_legacy_ole_text(path, "doc") | |
| raw_sections = [{"title": "Legacy Word Document", "raw_text": text}] | |
| elif ext == ".ppt": | |
| text = extract_legacy_ole_text(path, "ppt") | |
| raw_sections = [{"title": "Legacy PowerPoint Presentation", "raw_text": text}] | |
| elif ext == ".txt": | |
| text = extract_txt(path) | |
| blocks = [b.strip() for b in re.split(r'\n\s*\n+', text) if b.strip()] | |
| for idx, block in enumerate(blocks): | |
| raw_sections.append({ | |
| "title": f"Paragraph {idx + 1}", | |
| "raw_text": block | |
| }) | |
| # If extraction yielded no sections, try fallback to raw text | |
| if not raw_sections: | |
| fallback_text = raw_bytes.decode("utf-8", errors="ignore") | |
| raw_sections = [{"title": "Document Body", "raw_text": fallback_text}] | |
| # Build hierarchical tree with sanitized sentences | |
| tree_sections: list[dict[str, Any]] = [] | |
| global_sentence_id = 0 | |
| total_document_words = 0 | |
| current_char_offset = 0 | |
| for sec_idx, sec_data in enumerate(raw_sections): | |
| cleaned_sec_text = sanitize_text(sec_data.get("raw_text", "")) | |
| if not cleaned_sec_text: | |
| continue | |
| sec_sentences_raw = tokenize_sentences_robust(cleaned_sec_text) | |
| tree_sentences: list[dict[str, Any]] = [] | |
| for s_text in sec_sentences_raw: | |
| words = s_text.split() | |
| w_count = len(words) | |
| total_document_words += w_count | |
| start_idx = current_char_offset | |
| end_idx = start_idx + len(s_text) | |
| current_char_offset = end_idx + 1 | |
| tree_sentences.append({ | |
| "id": global_sentence_id, | |
| "text": s_text, | |
| "start_idx": start_idx, | |
| "end_idx": end_idx, | |
| "word_count": w_count | |
| }) | |
| global_sentence_id += 1 | |
| if tree_sentences: | |
| tree_sections.append({ | |
| "section_index": sec_idx, | |
| "title": sec_data.get("title", f"Section {sec_idx + 1}"), | |
| "raw_text": cleaned_sec_text, | |
| "sentences": tree_sentences | |
| }) | |
| if total_document_words < MIN_DOCUMENT_WORDS: | |
| raise DocumentExtractionError( | |
| f"Document contains only {total_document_words} words. Minimum required is {MIN_DOCUMENT_WORDS} words for reliable statistical analysis.", | |
| status_code=400, | |
| ) | |
| return { | |
| "document_name": original_filename, | |
| "sha256_hash": sha256_hash, | |
| "total_words": total_document_words, | |
| "total_sentences": global_sentence_id, | |
| "sections": tree_sections | |
| } | |
| def extract_text(file_path: Path | str, original_filename: str) -> str: | |
| """ | |
| Backwards-compatible helper returning the full sanitized text of the document. | |
| """ | |
| tree = build_hierarchical_document_tree(file_path, original_filename) | |
| all_texts = [] | |
| for sec in tree.get("sections", []): | |
| all_texts.append(sec.get("raw_text", "")) | |
| return "\n\n".join(all_texts) | |