diff --git a/apps/worker/app/services/document_ingestion/office_compat_normalizer.py b/apps/worker/app/services/document_ingestion/office_compat_normalizer.py new file mode 100644 index 000000000..10ce01a4b --- /dev/null +++ b/apps/worker/app/services/document_ingestion/office_compat_normalizer.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import NoReturn + +from app.services.document_parser.conversion.legacy_converter import ( + normalize_docx_variant, + normalize_xlsx_variant, +) +from app.services.document_parser.orchestration.office_container_inspection import ( + inspect_office_container, + read_override_content_type, +) +from loguru import logger + +from shared.core.exceptions.domain_exceptions import ValidationException + +OLE_CFBF_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1") + +_STANDARD_MAIN_CONTENT_TYPES: dict[str, str] = { + ".docx": ( + "application/vnd.openxmlformats-officedocument." + "wordprocessingml.document.main+xml" + ), + ".xlsx": ( + "application/vnd.openxmlformats-officedocument." + "spreadsheetml.sheet.main+xml" + ), +} +_MAIN_PART_BY_EXTENSION: dict[str, str] = { + ".docx": "/word/document.xml", + ".xlsx": "/xl/workbook.xml", +} +_VARIANT_CONTENT_TYPE_MARKERS: tuple[str, ...] = ( + "macroEnabled.main+xml", + "template.main+xml", +) +_NORMALIZED_OUTPUT_DIRNAME = "office_compat" + + +@dataclass(frozen=True) +class OfficeCompatNormalization: + file_path: str + conversion: dict[str, object] | None = None + + +def normalize_office_source(file_path: str) -> OfficeCompatNormalization: + """Convert known DOCX/XLSX OOXML variants; reject encrypted OLE containers.""" + extension = os.path.splitext(file_path)[1].lower() + if extension not in _STANDARD_MAIN_CONTENT_TYPES: + return OfficeCompatNormalization(file_path=file_path) + + if _has_ole_cfbf_signature(file_path): + _raise_encrypted_office_file(extension) + + inspection = inspect_office_container(file_path) + if inspection is None or inspection.content_types_xml is None: + return OfficeCompatNormalization(file_path=file_path) + + content_type = read_override_content_type( + inspection.content_types_xml, + _MAIN_PART_BY_EXTENSION[extension], + ) + if content_type is None: + return OfficeCompatNormalization(file_path=file_path) + if content_type == _STANDARD_MAIN_CONTENT_TYPES[extension]: + return OfficeCompatNormalization(file_path=file_path) + if not _is_known_ooxml_variant(content_type): + return OfficeCompatNormalization(file_path=file_path) + + outdir = os.path.join(os.path.dirname(file_path), _NORMALIZED_OUTPUT_DIRNAME) + if extension == ".docx": + converted_path, _converted_name = normalize_docx_variant(file_path, outdir) + else: + converted_path, _converted_name = normalize_xlsx_variant(file_path, outdir) + logger.info( + "Office compatibility conversion applied: " + f"source_path={file_path}, normalized_path={converted_path}, " + f"content_type={content_type}" + ) + return OfficeCompatNormalization( + file_path=converted_path, + conversion={"content_type": content_type}, + ) + + +def _has_ole_cfbf_signature(file_path: str) -> bool: + with open(file_path, "rb") as handle: + header = handle.read(len(OLE_CFBF_SIGNATURE)) + return header == OLE_CFBF_SIGNATURE + + +def _is_known_ooxml_variant(content_type: str) -> bool: + return any(marker in content_type for marker in _VARIANT_CONTENT_TYPE_MARKERS) + + +def _raise_encrypted_office_file(extension: str) -> NoReturn: + raise ValidationException( + user_message=( + f"Invalid file: the uploaded {extension} file is encrypted or " + "password-protected. Please unlock the file and upload again." + ), + violations=[ + { + "field": "file", + "description": "Encrypted or password-protected Office file", + } + ], + ) diff --git a/apps/worker/app/services/document_ingestion/source_preparation.py b/apps/worker/app/services/document_ingestion/source_preparation.py index ef7093136..1aa6cb590 100644 --- a/apps/worker/app/services/document_ingestion/source_preparation.py +++ b/apps/worker/app/services/document_ingestion/source_preparation.py @@ -6,7 +6,13 @@ from app.services.document_ingestion.file_size_policy import ( build_file_size_limit_message, ) -from app.services.document_ingestion.processing_context import ParseJobContext +from app.services.document_ingestion.office_compat_normalizer import ( + normalize_office_source, +) +from app.services.document_ingestion.processing_context import ( + ParseJobContext, + persist_job_metadata_updates, +) from app.services.document_parser.support.internal_parse_name import ( prepare_internal_parse_input, ) @@ -64,10 +70,18 @@ def prepare_source_file( f"local_path={prepared_parse_input.file_path}" ) + normalized_source = normalize_office_source(prepared_parse_input.file_path) + if normalized_source.conversion is not None: + persist_job_metadata_updates( + job_id=job_id, + job_context=job_context, + metadata_updates={"office_compat": normalized_source.conversion}, + ) + return PreparedSourceFile( source_file_name=source_file_name, internal_parse_name=prepared_parse_input.internal_filename, - local_file_path=prepared_parse_input.file_path, + local_file_path=normalized_source.file_path, file_extension=file_extension, ) diff --git a/apps/worker/app/services/document_parser/conversion/legacy_converter.py b/apps/worker/app/services/document_parser/conversion/legacy_converter.py index a602d67b8..0f6a5ebd3 100644 --- a/apps/worker/app/services/document_parser/conversion/legacy_converter.py +++ b/apps/worker/app/services/document_parser/conversion/legacy_converter.py @@ -51,7 +51,7 @@ def _convert_with_libreoffice( expected_output_ext: str, operation: str, ) -> tuple[str, str]: - """Convert a legacy Office document to an OOXML format via LibreOffice.""" + """Convert an Office document to an OOXML format via LibreOffice.""" soffice_path = resolve_libreoffice_binary() os.makedirs(outdir, exist_ok=True) @@ -124,3 +124,25 @@ def xls_to_xlsx(xls_path: str, outdir: str = ".") -> tuple[str, str]: expected_output_ext="xlsx", operation="convert_xls_to_xlsx", ) + + +def normalize_docx_variant(source_path: str, outdir: str = ".") -> tuple[str, str]: + """Repair a non-standard DOCX container (macro/template content type) via LibreOffice.""" + return _convert_with_libreoffice( + source_path=source_path, + outdir=outdir, + convert_to_arg="docx", + expected_output_ext="docx", + operation="normalize_docx_variant", + ) + + +def normalize_xlsx_variant(source_path: str, outdir: str = ".") -> tuple[str, str]: + """Repair a non-standard XLSX container (macro/template content type) via LibreOffice.""" + return _convert_with_libreoffice( + source_path=source_path, + outdir=outdir, + convert_to_arg="xlsx", + expected_output_ext="xlsx", + operation="normalize_xlsx_variant", + ) diff --git a/apps/worker/app/services/document_parser/orchestration/office_container_inspection.py b/apps/worker/app/services/document_parser/orchestration/office_container_inspection.py new file mode 100644 index 000000000..56ed09a79 --- /dev/null +++ b/apps/worker/app/services/document_parser/orchestration/office_container_inspection.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import zipfile +from dataclasses import dataclass +from xml.etree import ElementTree + +CONTENT_TYPES_MEMBER: str = "[Content_Types].xml" +CONTENT_TYPES_NAMESPACE: str = ( + "http://schemas.openxmlformats.org/package/2006/content-types" +) + + +@dataclass(frozen=True) +class OfficeContainerInspection: + member_names: frozenset[str] + content_types_xml: bytes | None + + +def inspect_office_container(file_path: str) -> OfficeContainerInspection | None: + """Return ZIP members and Content_Types.xml when the path is a readable ZIP.""" + if not zipfile.is_zipfile(file_path): + return None + try: + with zipfile.ZipFile(file_path, "r") as archive: + member_names = frozenset(archive.namelist()) + content_types_xml = ( + archive.read(CONTENT_TYPES_MEMBER) + if CONTENT_TYPES_MEMBER in member_names + else None + ) + except zipfile.BadZipFile: + return None + return OfficeContainerInspection( + member_names=member_names, + content_types_xml=content_types_xml, + ) + + +def read_override_content_type( + content_types_xml: bytes, + part_name: str, +) -> str | None: + """Return the Override ContentType for ``part_name``, or None if absent.""" + try: + root = ElementTree.fromstring(content_types_xml) + except ElementTree.ParseError: + return None + + override_tag = f"{{{CONTENT_TYPES_NAMESPACE}}}Override" + for override in root.findall(override_tag): + if override.get("PartName") == part_name: + return override.get("ContentType") + return None + + +__all__ = [ + "CONTENT_TYPES_MEMBER", + "CONTENT_TYPES_NAMESPACE", + "OfficeContainerInspection", + "inspect_office_container", + "read_override_content_type", +] diff --git a/apps/worker/app/services/document_parser/orchestration/office_container_validator.py b/apps/worker/app/services/document_parser/orchestration/office_container_validator.py index 3009603c1..9445a64e1 100644 --- a/apps/worker/app/services/document_parser/orchestration/office_container_validator.py +++ b/apps/worker/app/services/document_parser/orchestration/office_container_validator.py @@ -1,9 +1,13 @@ from __future__ import annotations -import zipfile from dataclasses import dataclass +from typing import NoReturn from app.services.document_parser.orchestration.format_router import DocumentFormat +from app.services.document_parser.orchestration.office_container_inspection import ( + CONTENT_TYPES_MEMBER, + inspect_office_container, +) from shared.core.exceptions.domain_exceptions import ValidationException @@ -29,7 +33,6 @@ def violation_description(self) -> str: ) -_CONTENT_TYPES_MEMBER: str = "[Content_Types].xml" _OFFICE_CONTAINER_REQUIREMENTS: dict[ DocumentFormat, _OfficeContainerRequirement, @@ -61,23 +64,18 @@ def validate_office_container( if requirement is None: return - if not zipfile.is_zipfile(file_path): + inspection = inspect_office_container(file_path) + if inspection is None: _raise_invalid_office_file(requirement) - try: - with zipfile.ZipFile(file_path, "r") as archive: - member_names = set(archive.namelist()) - except zipfile.BadZipFile as exc: - raise _build_invalid_office_file_exception(requirement) from exc - if ( - _CONTENT_TYPES_MEMBER not in member_names - or requirement.required_member not in member_names + CONTENT_TYPES_MEMBER not in inspection.member_names + or requirement.required_member not in inspection.member_names ): _raise_invalid_office_file(requirement) -def _raise_invalid_office_file(requirement: _OfficeContainerRequirement) -> None: +def _raise_invalid_office_file(requirement: _OfficeContainerRequirement) -> NoReturn: raise _build_invalid_office_file_exception(requirement) diff --git a/apps/worker/tests/contract/test_parse_task_contract.py b/apps/worker/tests/contract/test_parse_task_contract.py index 1137efe79..da630eaac 100644 --- a/apps/worker/tests/contract/test_parse_task_contract.py +++ b/apps/worker/tests/contract/test_parse_task_contract.py @@ -1,16 +1,35 @@ from __future__ import annotations import json +import os +import zipfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path from uuid import uuid4 +from xml.etree import ElementTree import pandas as pd import pytest - +from docx import Document + +from app.services.document_ingestion.office_compat_normalizer import ( + OLE_CFBF_SIGNATURE, +) +from app.services.document_parser.orchestration.office_container_inspection import ( + CONTENT_TYPES_MEMBER, + CONTENT_TYPES_NAMESPACE, +) from app.services.document_parser.orchestration.parse_output import ParseOutput from support.worker_parse_contract import WorkerParseContract +_DOCX_STANDARD_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" +) +_DOCX_MACRO_CONTENT_TYPE = ( + "application/vnd.ms-word.document.macroEnabled.main+xml" +) +_COMPAT_BODY_MARKER = "office-compat-body-marker" + _REPO_ROOT: Path = Path(__file__).resolve().parents[4] _FIXTURES_ROOT: Path = _REPO_ROOT / "apps" / "worker" / "tests" / "fixtures" _SAMPLE_XLSX_PATH: Path = _FIXTURES_ROOT / "sample_100rows.xlsx" @@ -715,6 +734,146 @@ def test_parse_task_should_report_invalid_docx_as_client_file_error( } +def _write_docx_with_body(file_path: Path, body_text: str) -> None: + document = Document() + document.add_paragraph(body_text) + document.save(file_path) + + +def _rewrite_docx_main_content_type( + source_path: Path, + destination_path: Path, + content_type: str, +) -> None: + override_tag = f"{{{CONTENT_TYPES_NAMESPACE}}}Override" + with zipfile.ZipFile(source_path, "r") as source_archive: + with zipfile.ZipFile(destination_path, "w") as destination_archive: + for info in source_archive.infolist(): + data = source_archive.read(info.filename) + if info.filename == CONTENT_TYPES_MEMBER: + root = ElementTree.fromstring(data) + for override in root.findall(override_tag): + if override.get("PartName") == "/word/document.xml": + override.set("ContentType", content_type) + data = ElementTree.tostring( + root, + encoding="UTF-8", + xml_declaration=True, + ) + destination_archive.writestr(info, data) + + +def _restore_standard_docx_content_type( + source_path: str, + outdir: str = ".", +) -> tuple[str, str]: + os.makedirs(outdir, exist_ok=True) + output_name = f"{Path(source_path).stem}.docx" + output_path = os.path.join(outdir, output_name) + _rewrite_docx_main_content_type( + Path(source_path), + Path(output_path), + _DOCX_STANDARD_CONTENT_TYPE, + ) + return output_path, output_name + + +def test_parse_task_should_parse_macro_content_type_docx_after_compat_conversion( + worker_contract_environment: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + contract = WorkerParseContract.create() + contract.use_workspace_root(monkeypatch, tmp_path) + contract.use_billing(monkeypatch, is_enabled=True) + + source_docx = tmp_path / "plain.docx" + _write_docx_with_body(source_docx, _COMPAT_BODY_MARKER) + variant_docx = tmp_path / "macro-named.docx" + _rewrite_docx_main_content_type( + source_docx, + variant_docx, + _DOCX_MACRO_CONTENT_TYPE, + ) + job = contract.create_file_job( + source_file_name="contract-macro.docx", + job_id_prefix="job_macro_docx", + ) + contract.upload_source_file( + local_file_path=variant_docx, + s3_key=job["s3_key"], + ) + monkeypatch.setattr( + "app.services.document_ingestion.office_compat_normalizer.normalize_docx_variant", + _restore_standard_docx_content_type, + ) + + celery_result = contract.enqueue_parse_task( + job_id=job["job_id"], + user_id=job["user_id"], + ) + + assert celery_result.successful() + observed = contract.observe_successful_job(job["job_id"]) + document_chunks = observed["document_chunks"] + assert any( + _COMPAT_BODY_MARKER in str(row["content"]) for row in document_chunks + ) + metadata = contract.get_job_metadata(job["job_id"]) + assert metadata["office_compat"] == { + "content_type": _DOCX_MACRO_CONTENT_TYPE, + } + assert contract.find_task_workspaces(tmp_path, job["job_id"]) == [] + + +def test_parse_task_should_report_encrypted_docx_as_client_file_error( + worker_contract_environment: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + contract = WorkerParseContract.create() + contract.use_workspace_root(monkeypatch, tmp_path) + contract.use_billing(monkeypatch, is_enabled=True) + + encrypted_docx = tmp_path / "locked.docx" + encrypted_docx.write_bytes(OLE_CFBF_SIGNATURE + b"\x00") + job = contract.create_file_job( + source_file_name="contract-encrypted.docx", + job_id_prefix="job_encrypted_docx", + ) + contract.upload_source_file( + local_file_path=encrypted_docx, + s3_key=job["s3_key"], + ) + + celery_result = contract.enqueue_parse_task( + job_id=job["job_id"], + user_id=job["user_id"], + ) + + assert celery_result.failed() + assert contract.find_task_workspaces(tmp_path, job["job_id"]) == [] + + job_row = contract.observe_job_status(job["job_id"]) + assert job_row["status"] == "failed" + assert job_row["error_code"] == "INVALID_ARGUMENT" + assert job_row["error_message"] == ( + "Invalid file: the uploaded .docx file is encrypted or " + "password-protected. Please unlock the file and upload again." + ) + assert contract.count_job_results(job["job_id"]) == 0 + + metadata = contract.get_job_metadata(job["job_id"]) + assert metadata["error_details"] == { + "violations": [ + { + "field": "file", + "description": "Encrypted or password-protected Office file", + } + ] + } + + def test_should_reject_pdf_when_page_count_exceeds_configured_limit( worker_contract_environment: None, monkeypatch: pytest.MonkeyPatch, diff --git a/apps/worker/tests/unit/test_office_compat_normalizer.py b/apps/worker/tests/unit/test_office_compat_normalizer.py new file mode 100644 index 000000000..05e9c9c87 --- /dev/null +++ b/apps/worker/tests/unit/test_office_compat_normalizer.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path +from xml.etree import ElementTree + +import openpyxl +import pytest +from docx import Document + +from app.services.document_parser.orchestration.office_container_inspection import ( + CONTENT_TYPES_MEMBER, + CONTENT_TYPES_NAMESPACE, +) +from shared.core.exceptions.domain_exceptions import ValidationException + + +def _compat_module(): + from app.services.document_ingestion import office_compat_normalizer + + return office_compat_normalizer + +_DOCX_MACRO_CONTENT_TYPE = ( + "application/vnd.ms-word.document.macroEnabled.main+xml" +) +_DOCX_TEMPLATE_CONTENT_TYPE = ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml" +) +_XLSX_MACRO_CONTENT_TYPE = ( + "application/vnd.ms-excel.sheet.macroEnabled.main+xml" +) + + +def _write_docx(path: Path) -> None: + document = Document() + document.add_paragraph("compat-body") + document.save(path) + + +def _write_xlsx(path: Path) -> None: + workbook = openpyxl.Workbook() + workbook.active["A1"] = "compat-body" + workbook.save(path) + + +def _rewrite_override_content_type( + source_path: Path, + destination_path: Path, + part_name: str, + content_type: str, +) -> None: + override_tag = f"{{{CONTENT_TYPES_NAMESPACE}}}Override" + with zipfile.ZipFile(source_path, "r") as source_archive: + with zipfile.ZipFile(destination_path, "w") as destination_archive: + for info in source_archive.infolist(): + data = source_archive.read(info.filename) + if info.filename == CONTENT_TYPES_MEMBER: + root = ElementTree.fromstring(data) + for override in root.findall(override_tag): + if override.get("PartName") == part_name: + override.set("ContentType", content_type) + data = ElementTree.tostring( + root, + encoding="UTF-8", + xml_declaration=True, + ) + destination_archive.writestr(info, data) + + +def test_standard_docx_is_left_unchanged(tmp_path: Path) -> None: + source_path = tmp_path / "plain.docx" + _write_docx(source_path) + + result = _compat_module().normalize_office_source(str(source_path)) + assert result.file_path == str(source_path) + assert result.conversion is None + + +def test_standard_xlsx_is_left_unchanged(tmp_path: Path) -> None: + source_path = tmp_path / "plain.xlsx" + _write_xlsx(source_path) + + result = _compat_module().normalize_office_source(str(source_path)) + assert result.file_path == str(source_path) + assert result.conversion is None + + +def test_non_office_extension_is_left_unchanged(tmp_path: Path) -> None: + source_path = tmp_path / "notes.pdf" + source_path.write_bytes(b"%PDF") + + result = _compat_module().normalize_office_source(str(source_path)) + assert result.file_path == str(source_path) + assert result.conversion is None + + +def test_unreadable_zip_docx_is_left_unchanged(tmp_path: Path) -> None: + source_path = tmp_path / "broken.docx" + source_path.write_bytes(b"this is not a docx package") + + result = _compat_module().normalize_office_source(str(source_path)) + assert result.file_path == str(source_path) + assert result.conversion is None + + +def test_unknown_content_type_is_left_unchanged(tmp_path: Path) -> None: + source_path = tmp_path / "plain.docx" + _write_docx(source_path) + variant_path = tmp_path / "unknown.docx" + _rewrite_override_content_type( + source_path, + variant_path, + "/word/document.xml", + "application/octet-stream", + ) + + result = _compat_module().normalize_office_source(str(variant_path)) + assert result.file_path == str(variant_path) + assert result.conversion is None + + +def test_macro_docx_is_converted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_path = tmp_path / "plain.docx" + _write_docx(source_path) + variant_path = tmp_path / "macro.docx" + _rewrite_override_content_type( + source_path, + variant_path, + "/word/document.xml", + _DOCX_MACRO_CONTENT_TYPE, + ) + converted_path = tmp_path / "converted.docx" + _write_docx(converted_path) + + def fake_normalize(path: str, outdir: str = ".") -> tuple[str, str]: + assert path == str(variant_path) + return str(converted_path), converted_path.name + + monkeypatch.setattr(_compat_module(), "normalize_docx_variant", fake_normalize) + + result = _compat_module().normalize_office_source(str(variant_path)) + assert result.file_path == str(converted_path) + assert result.conversion == {"content_type": _DOCX_MACRO_CONTENT_TYPE} + + +def test_template_docx_is_converted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_path = tmp_path / "plain.docx" + _write_docx(source_path) + variant_path = tmp_path / "template.docx" + _rewrite_override_content_type( + source_path, + variant_path, + "/word/document.xml", + _DOCX_TEMPLATE_CONTENT_TYPE, + ) + converted_path = tmp_path / "converted.docx" + _write_docx(converted_path) + + monkeypatch.setattr( + _compat_module(), + "normalize_docx_variant", + lambda path, outdir=".": (str(converted_path), converted_path.name), + ) + + result = _compat_module().normalize_office_source(str(variant_path)) + assert result.file_path == str(converted_path) + assert result.conversion == {"content_type": _DOCX_TEMPLATE_CONTENT_TYPE} + + +def test_macro_xlsx_is_converted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_path = tmp_path / "plain.xlsx" + _write_xlsx(source_path) + variant_path = tmp_path / "macro.xlsx" + _rewrite_override_content_type( + source_path, + variant_path, + "/xl/workbook.xml", + _XLSX_MACRO_CONTENT_TYPE, + ) + converted_path = tmp_path / "converted.xlsx" + _write_xlsx(converted_path) + + monkeypatch.setattr( + _compat_module(), + "normalize_xlsx_variant", + lambda path, outdir=".": (str(converted_path), converted_path.name), + ) + + result = _compat_module().normalize_office_source(str(variant_path)) + assert result.file_path == str(converted_path) + assert result.conversion == {"content_type": _XLSX_MACRO_CONTENT_TYPE} + + +def test_ole_container_is_rejected_as_encrypted(tmp_path: Path) -> None: + source_path = tmp_path / "locked.docx" + source_path.write_bytes(_compat_module().OLE_CFBF_SIGNATURE + b"\x00") + + with pytest.raises(ValidationException) as raised: + _compat_module().normalize_office_source(str(source_path)) + + assert raised.value.user_message == ( + "Invalid file: the uploaded .docx file is encrypted or " + "password-protected. Please unlock the file and upload again." + ) + assert raised.value.violations == [ + { + "field": "file", + "description": "Encrypted or password-protected Office file", + } + ] + + +def test_ole_xlsx_container_is_rejected_as_encrypted(tmp_path: Path) -> None: + source_path = tmp_path / "locked.xlsx" + source_path.write_bytes(_compat_module().OLE_CFBF_SIGNATURE + b"\x00") + + with pytest.raises(ValidationException) as raised: + _compat_module().normalize_office_source(str(source_path)) + + assert ".xlsx" in raised.value.user_message diff --git a/packages/shared-python/shared/models/schemas/job_metadata.py b/packages/shared-python/shared/models/schemas/job_metadata.py index 0162a257a..ec7d23454 100644 --- a/packages/shared-python/shared/models/schemas/job_metadata.py +++ b/packages/shared-python/shared/models/schemas/job_metadata.py @@ -40,6 +40,10 @@ class JobMetadataBase(BaseModel): None, description="Worker processing stages, including token_usage and timing_ms", ) + office_compat: Optional[Dict[str, Any]] = Field( + None, + description="Office compatibility conversion applied before parse", + ) # result_mode was removed and is no longer supported. # Source-file fields.