diff --git a/backend/services/hwpx_recognition.py b/backend/services/hwpx_recognition.py
index 325f5417c..a5b6292b9 100644
--- a/backend/services/hwpx_recognition.py
+++ b/backend/services/hwpx_recognition.py
@@ -1,4 +1,11 @@
-"""Safely extract bounded text and graph records from an HWPX package."""
+"""Safely recognize HWPX section text into Naruon's content graph.
+
+The importer retains HWPX bytes for deferred processing. This module performs the
+next deterministic worker-side boundary: it revalidates the ZIP package,
+follows the OPF spine order from ``Contents/content.hpf``, parses section XML
+with defused XML semantics, and emits paragraph-level provenance without
+extracting files, executing active content, or fetching external resources.
+"""
from __future__ import annotations
@@ -7,144 +14,292 @@
import re
import zipfile
from dataclasses import dataclass
+from pathlib import PurePosixPath
+from xml.etree.ElementTree import ParseError
-from defusedxml import ElementTree
-from services.content_graph import ParseResult, PdfDomSection, parse_pdf_dom
+from defusedxml import ElementTree as DefusedElementTree
+from defusedxml.common import DefusedXmlException
-HWPX_CONTENT_TYPE = "application/hwp+zip"
-MAX_HWPX_SECTION_XML_BYTES = 8 * 1024 * 1024
-MAX_HWPX_TOTAL_XML_BYTES = 32 * 1024 * 1024
-MAX_HWPX_TEXT_CHARS = 1_000_000
-_SECTION_NAME_PATTERN = re.compile(r"Contents/section[0-9]+\.xml\Z")
-_FORBIDDEN_XML_DECLARATION = re.compile(
- br" str:
+ """Return the local XML name without trusting a particular namespace URI."""
+
+ return tag.rsplit("}", 1)[-1]
+
+
+def _safe_member_name(name: str) -> str:
+ """Validate a ZIP member as one normalized package-internal POSIX path."""
+ if not name or "\\" in name or "\x00" in name:
+ raise ValueError("HWPX package contains an unsafe member path")
+ path = PurePosixPath(name)
+ if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
+ raise ValueError("HWPX package contains an unsafe member path")
+ return path.as_posix()
-def recognize_hwpx(
+
+def _package_entries(archive: zipfile.ZipFile) -> dict[str, zipfile.ZipInfo]:
+ """Return unique, unencrypted HWPX members within metadata budgets."""
+
+ entries = archive.infolist()
+ if not 0 < len(entries) <= MAX_HWPX_PACKAGE_ENTRIES:
+ raise ValueError("HWPX package exceeds the member-count limit")
+
+ result: dict[str, zipfile.ZipInfo] = {}
+ aggregate_name_bytes = 0
+ for entry in entries:
+ name = _safe_member_name(entry.filename)
+ aggregate_name_bytes += len(name.encode("utf-8", errors="surrogatepass"))
+ if aggregate_name_bytes > MAX_HWPX_MEMBER_NAME_BYTES:
+ raise ValueError("HWPX package exceeds the member-name metadata limit")
+ if name in result:
+ raise ValueError("HWPX package contains duplicate member paths")
+ if entry.flag_bits & 0x1:
+ raise ValueError("HWPX package contains encrypted members")
+ result[name] = entry
+ return result
+
+
+def _read_member(
+ archive: zipfile.ZipFile,
+ entry: zipfile.ZipInfo,
*,
- hwpx_bytes: bytes,
+ label: str,
+ max_bytes: int = MAX_HWPX_XML_MEMBER_BYTES,
+) -> bytes:
+ """Read one already-selected XML member within its expansion budget."""
+
+ if entry.is_dir() or entry.file_size > max_bytes:
+ raise ValueError(f"HWPX {label} XML member exceeds the expansion limit")
+ try:
+ payload = archive.read(entry)
+ except (NotImplementedError, zipfile.BadZipFile) as exc:
+ raise ValueError(f"HWPX {label} XML member could not be read") from exc
+ if len(payload) != entry.file_size or len(payload) > max_bytes:
+ raise ValueError(f"HWPX {label} XML member exceeds the expansion limit")
+ return payload
+
+
+def _parse_xml(payload: bytes, *, label: str):
+ """Parse one bounded XML member with entity/DTD defenses enabled."""
+
+ try:
+ return DefusedElementTree.fromstring(payload)
+ except (DefusedXmlException, ParseError, ValueError) as exc:
+ raise ValueError(f"HWPX {label} contains unsafe XML") from exc
+
+
+def _resolve_manifest_href(href: str) -> str:
+ """Resolve an OPF item href only to the standard HWPX section namespace."""
+
+ value = href.strip()
+ if not value or "\\" in value or "\x00" in value or "?" in value or "#" in value:
+ raise ValueError("HWPX content.hpf contains an unsafe manifest href")
+ raw_path = PurePosixPath(value)
+ if raw_path.is_absolute() or any(part in {"", ".", ".."} for part in raw_path.parts):
+ raise ValueError("HWPX content.hpf contains an unsafe manifest href")
+
+ if raw_path.parts and raw_path.parts[0] == "Contents":
+ resolved = raw_path.as_posix()
+ else:
+ resolved = (PurePosixPath("Contents") / raw_path).as_posix()
+ if not _SECTION_PATH_RE.fullmatch(resolved):
+ raise ValueError("HWPX content.hpf contains a non-section spine target")
+ return resolved
+
+
+def _section_paths_from_spine(content_hpf_root) -> tuple[str, ...]:
+ """Resolve section member paths using OPF manifest identity and spine order."""
+
+ manifest_element = next(
+ (child for child in content_hpf_root if _local_name(child.tag) == "manifest"),
+ None,
+ )
+ spine_element = next(
+ (child for child in content_hpf_root if _local_name(child.tag) == "spine"),
+ None,
+ )
+ if manifest_element is None or spine_element is None:
+ raise ValueError("HWPX content.hpf is missing manifest or spine metadata")
+
+ manifest: dict[str, str] = {}
+ for item in manifest_element:
+ if _local_name(item.tag) != "item":
+ continue
+ item_id = (item.get("id") or "").strip()
+ href = (item.get("href") or "").strip()
+ if not item_id or not href or item_id in manifest:
+ raise ValueError("HWPX content.hpf contains ambiguous manifest identity")
+ manifest[item_id] = href
+
+ section_paths: list[str] = []
+ for itemref in spine_element:
+ if _local_name(itemref.tag) != "itemref":
+ continue
+ item_id = (itemref.get("idref") or "").strip()
+ if not item_id or item_id not in manifest:
+ raise ValueError("HWPX spine item cannot be resolved through the manifest")
+ section_paths.append(_resolve_manifest_href(manifest[item_id]))
+
+ if not section_paths or len(section_paths) != len(set(section_paths)):
+ raise ValueError("HWPX spine must reference unique section members")
+ return tuple(section_paths)
+
+
+def _paragraph_text(paragraph) -> str:
+ """Extract text controls from one paragraph without duplicating nested paragraphs."""
+
+ parts: list[str] = []
+
+ def visit(element) -> None:
+ for child in element:
+ local_name = _local_name(child.tag)
+ if local_name == "p":
+ continue
+ if local_name == "t":
+ parts.append("".join(child.itertext()))
+ elif local_name in {"lineBreak", "br"}:
+ parts.append("\n")
+ elif local_name == "tab":
+ parts.append("\t")
+ else:
+ visit(child)
+
+ visit(paragraph)
+ return "".join(parts).strip()
+
+
+def _section_paragraphs(section_root) -> tuple[str, ...]:
+ """Return non-empty paragraphs in document order from one HWPX section."""
+
+ paragraphs: list[str] = []
+ for element in section_root.iter():
+ if _local_name(element.tag) != "p":
+ continue
+ text = _paragraph_text(element)
+ if text:
+ paragraphs.append(text)
+ return tuple(paragraphs)
+
+
+def recognize_hwpx_package(
+ payload: bytes,
+ *,
+ filename: str,
+ source_kind: str,
source_record_uid: str,
- display_name: str = "",
) -> HwpxRecognitionRecords:
- """Extract text from bounded HWPX section XML without executing content.
+ """Recognize bounded HWPX section text and paragraph provenance.
- Only canonical ``Contents/sectionN.xml`` members are read. Encrypted or
- unsupported compression members, XML entity declarations, oversized
- members, and empty documents fail closed before graph records are created.
+ ``Contents/content.hpf`` is the ordering authority: manifest IDs resolve
+ package paths and spine references define reading order. Each selected XML
+ member is bounded before decompression and parsed with ``defusedxml``.
+ Images, OLE objects, external resources, macros, and non-section package
+ members are intentionally not interpreted by this slice.
"""
- sections: list[PdfDomSection] = []
- total_xml_bytes = 0
+
+ if not isinstance(payload, bytes) or not payload.startswith(b"PK"):
+ raise ValueError("Pending attachment payload is not a HWPX package")
+
try:
- with zipfile.ZipFile(io.BytesIO(hwpx_bytes)) as archive:
- section_infos = sorted(
- (
- info
- for info in archive.infolist()
- if _SECTION_NAME_PATTERN.fullmatch(info.filename)
- ),
- key=lambda info: int(
- info.filename.removeprefix("Contents/section").removesuffix(
- ".xml"
- )
- ),
- )
- if not section_infos:
- raise HwpxRecognitionError("HWPX package has no section XML")
- if len({info.filename for info in section_infos}) != len(section_infos):
- raise HwpxRecognitionError("HWPX package has duplicate sections")
-
- for info in section_infos:
- if info.flag_bits & 0x1 or info.compress_type not in (
- zipfile.ZIP_STORED,
- zipfile.ZIP_DEFLATED,
- ):
- raise HwpxRecognitionError("HWPX section compression is not allowed")
- if info.file_size > MAX_HWPX_SECTION_XML_BYTES:
- raise HwpxRecognitionError("HWPX section exceeds the XML size limit")
- total_xml_bytes += info.file_size
- if total_xml_bytes > MAX_HWPX_TOTAL_XML_BYTES:
- raise HwpxRecognitionError("HWPX XML exceeds the total size limit")
- xml_bytes = archive.read(info)
- if len(xml_bytes) != info.file_size:
- raise HwpxRecognitionError("HWPX section size changed while reading")
- sections.extend(_parse_section_xml(xml_bytes))
- except HwpxRecognitionError:
- raise
- except (
- OSError,
- RuntimeError,
- ValueError,
- zipfile.BadZipFile,
- ElementTree.ParseError,
- ) as exc:
- raise HwpxRecognitionError("HWPX package could not be safely read") from exc
-
- paragraphs = tuple(
- paragraph
- for section in sections
- for paragraph in section.paragraphs
- if paragraph.strip()
- )
- parse_text = "\n\n".join(paragraphs)
- if not parse_text.strip():
- raise HwpxRecognitionError("HWPX package contains no readable text")
- if len(parse_text) > MAX_HWPX_TEXT_CHARS:
- raise HwpxRecognitionError("HWPX text exceeds the parse size limit")
+ archive = zipfile.ZipFile(io.BytesIO(payload))
+ except (zipfile.BadZipFile, ValueError) as exc:
+ raise ValueError("Pending attachment payload is not a HWPX package") from exc
+
+ with archive:
+ entries = _package_entries(archive)
+ mimetype_entry = entries.get("mimetype")
+ version_entry = entries.get("version.xml")
+ content_hpf_entry = entries.get(_CONTENT_HPF_PATH)
+ if (
+ mimetype_entry is None
+ or version_entry is None
+ or content_hpf_entry is None
+ ):
+ raise ValueError("HWPX package is missing required identity metadata")
+ mimetype_payload = _read_member(
+ archive,
+ mimetype_entry,
+ label="mimetype",
+ max_bytes=128,
+ )
+ if mimetype_payload != _HWPX_MIMETYPE:
+ raise ValueError("HWPX package has an invalid mimetype member")
+
+ content_hpf_payload = _read_member(
+ archive,
+ content_hpf_entry,
+ label="content.hpf",
+ )
+ content_hpf_root = _parse_xml(content_hpf_payload, label="content.hpf")
+ section_paths = _section_paths_from_spine(content_hpf_root)
+
+ selected_entries: list[zipfile.ZipInfo] = []
+ expanded_total = len(content_hpf_payload)
+ for section_path in section_paths:
+ entry = entries.get(section_path)
+ if entry is None:
+ raise ValueError("HWPX spine section member is missing from the package")
+ if entry.file_size > MAX_HWPX_XML_MEMBER_BYTES:
+ raise ValueError("HWPX section XML member exceeds the expansion limit")
+ expanded_total += entry.file_size
+ if expanded_total > MAX_HWPX_TOTAL_XML_BYTES:
+ raise ValueError("HWPX selected XML exceeds the total expansion limit")
+ selected_entries.append(entry)
+
+ sections: list[PdfDomSection] = []
+ paragraph_count = 0
+ for entry in selected_entries:
+ section_payload = _read_member(archive, entry, label="section")
+ section_root = _parse_xml(section_payload, label="section")
+ paragraphs = _section_paragraphs(section_root)
+ paragraph_count += len(paragraphs)
+ sections.append(PdfDomSection(heading="", paragraphs=paragraphs))
- source_content_hash = hashlib.sha256(hwpx_bytes).hexdigest()
+ if paragraph_count == 0:
+ raise ValueError("HWPX package contains no readable paragraph text")
+
+ source_content_hash = hashlib.sha256(payload).hexdigest()
parse_result = parse_pdf_dom(
- source_kind="attachment",
+ source_kind=source_kind,
source_record_uid=source_record_uid,
sections=sections,
source_content_hash=source_content_hash,
- display_name=display_name,
- content_type=HWPX_CONTENT_TYPE,
+ display_name=filename,
+ content_type=HWPX_PARSE_CONTENT_TYPE,
+ )
+ parse_text = "\n\n".join(
+ segment.safe_text_content
+ for segment in parse_result.segments
+ if segment.segment_kind == "paragraph"
)
return HwpxRecognitionRecords(
parse_text=parse_text,
- source_content_hash=source_content_hash,
parse_result=parse_result,
+ section_count=len(sections),
+ paragraph_count=paragraph_count,
)
-
-
-def _parse_section_xml(xml_bytes: bytes) -> list[PdfDomSection]:
- """Parse one section into paragraph units without resolving declarations."""
- if _FORBIDDEN_XML_DECLARATION.search(xml_bytes):
- raise HwpxRecognitionError("HWPX XML declarations are not allowed")
- root = ElementTree.fromstring(xml_bytes)
- paragraphs: list[str] = []
- for element in root.iter():
- if _local_name(element.tag) != "p":
- continue
- text = "".join(
- node.text or ""
- for node in element.iter()
- if _local_name(node.tag) == "t"
- )
- normalized = " ".join(text.split())
- if normalized:
- paragraphs.append(normalized)
- if not paragraphs:
- return []
- return [PdfDomSection(heading="", paragraphs=tuple(paragraphs))]
-
-
-def _local_name(tag: str) -> str:
- """Return an XML local name while rejecting non-element tags."""
- if not isinstance(tag, str):
- return ""
- return tag.rsplit("}", maxsplit=1)[-1]
diff --git a/backend/services/newsdom_worker.py b/backend/services/newsdom_worker.py
index 469482ecd..951df16f9 100644
--- a/backend/services/newsdom_worker.py
+++ b/backend/services/newsdom_worker.py
@@ -1,18 +1,19 @@
-"""Background worker glue for NewsDOM PDF DOM recognition.
+"""Background worker glue for deferred PDF and HWPX recognition.
-Attachments and workspace documents whose PDF recognition was deferred at
-import time are processed here: the sidecar is called (via
-:mod:`services.newsdom_pdf_recognition`) and the returned tree is landed into
-the persisted attachment/document content and, for attachments, the
-``content_nodes`` / ``content_segments`` graph.
+PDF attachments and workspace documents whose NewsDOM recognition was deferred
+at import time are processed through the configured sidecar. HWPX attachments
+are processed locally through the bounded OWPML recognizer. Both paths land
+recognized attachment text and provenance into ``content_nodes`` /
+``content_segments`` without allowing a pending payload to masquerade as parsed.
The apply functions are deliberately session-free so they can be unit tested
-with in-memory model instances and a mocked NewsDOM client.
+with in-memory model instances and injected adapters.
"""
from __future__ import annotations
import asyncio
+import hashlib
import logging
import random
from collections.abc import Awaitable, Callable
@@ -31,15 +32,15 @@
from db.session import AsyncSessionLocal
from services.attachment_parser import (
HWP_CONVERSION_PENDING_STATUS,
- HWPX_XML_PACKAGE_PENDING_STATUS,
decode_deferred_attachment_payload,
)
from services.content_graph import ParseResult
from services.hwpx_recognition import (
- HWPX_CONTENT_TYPE,
- HwpxRecognitionError,
+ HWPX_FAILED_STATUS,
+ HWPX_PARSED_STATUS,
+ HWPX_PARSE_CONTENT_TYPE,
HwpxRecognitionRecords,
- recognize_hwpx,
+ recognize_hwpx_package,
)
from services.newsdom_client import (
NewsdomConfigurationError,
@@ -62,6 +63,9 @@
logger = logging.getLogger(__name__)
_sysrand = random.SystemRandom()
+HWPX_PENDING_STATUS = "hwpx_xml_package_pending"
+HWPX_PARSER_KEY = "hwpx"
+
# How the worker resolves a runtime config for an organization. Injectable so
# the per-item processors are unit-testable without a database.
ConfigResolver = Callable[
@@ -69,6 +73,20 @@
]
+def _attachment_source_record_uid(email: Email, attachment: Attachment) -> str:
+ """Return opaque provenance derived from stable message and source data."""
+ identity = "\x00".join(
+ (
+ email.message_id or "",
+ email.thread_id or "",
+ attachment.filename or "",
+ attachment.content or "",
+ )
+ )
+ digest = hashlib.sha256(identity.encode("utf-8", errors="surrogatepass"))
+ return f"attachment:{digest.hexdigest()[:32]}"
+
+
def _append_parse_result_to_attachment(
*,
email: Email,
@@ -139,11 +157,12 @@ def apply_hwpx_recognition_to_attachment(
attachment: Attachment,
records: HwpxRecognitionRecords,
) -> None:
- """Land locally extracted HWPX text and graph records on an attachment."""
+ """Land recognized HWPX text and provenance on one attachment."""
+
attachment.content = records.parse_text
- attachment.parse_content_type = HWPX_CONTENT_TYPE
- attachment.parser_key = "hwpx"
- attachment.parse_status = PDF_DOM_RECOGNITION_PARSED_STATUS
+ attachment.parse_content_type = HWPX_PARSE_CONTENT_TYPE
+ attachment.parser_key = HWPX_PARSER_KEY
+ attachment.parse_status = HWPX_PARSED_STATUS
attachment.parse_error_code = None
_append_parse_result_to_attachment(
email=email,
@@ -186,6 +205,29 @@ async def recognize_attachment_pdf(
return records
+def recognize_attachment_hwpx(
+ *,
+ email: Email,
+ attachment: Attachment,
+ hwpx_bytes: bytes,
+ source_record_uid: str,
+) -> HwpxRecognitionRecords:
+ """Recognize a HWPX package and land its ordered text and graph."""
+
+ records = recognize_hwpx_package(
+ hwpx_bytes,
+ filename=attachment.filename or "attachment.hwpx",
+ source_kind="attachment",
+ source_record_uid=source_record_uid,
+ )
+ apply_hwpx_recognition_to_attachment(
+ email=email,
+ attachment=attachment,
+ records=records,
+ )
+ return records
+
+
async def recognize_document_pdf(
*,
document: Document,
@@ -227,6 +269,14 @@ async def recognize_document_pdf(
}
+def _attachment_failed_status(attachment: Attachment) -> str:
+ """Return the parser-family-specific visible failure status."""
+
+ if attachment.parse_status == HWPX_PENDING_STATUS:
+ return HWPX_FAILED_STATUS
+ return PDF_DOM_RECOGNITION_FAILED_STATUS
+
+
async def process_pending_attachment(
*,
session: AsyncSession,
@@ -234,23 +284,59 @@ async def process_pending_attachment(
config_resolver: ConfigResolver = resolve_newsdom_config_from_db,
request_fn: ParseRequestFn = request_pdf_dom,
) -> str:
- """Recognize one pending attachment PDF, or record a safe outcome.
+ """Recognize one pending PDF or HWPX attachment, or record a safe outcome.
- Returns ``RESULT_RECOGNIZED`` on success, ``RESULT_PENDING`` when no active
- provider is configured yet (left pending to retry later), or
- ``RESULT_FAILED`` when the payload or the sidecar response is unusable (a
- visible failure status is recorded - never a false ``parsed``).
+ HWPX recognition is deterministic and local, so it never resolves a
+ NewsDOM provider. PDF recognition retains the provider-aware retry behavior:
+ ``RESULT_PENDING`` means no active provider is usable yet. Invalid retained
+ bytes or recognition failures are always recorded explicitly and never
+ reported as parsed.
"""
email = attachment.email
if email is None:
- attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS
+ attachment.parse_status = _attachment_failed_status(attachment)
attachment.parse_error_code = "orphan_attachment"
return RESULT_FAILED
- expected_content_type = attachment.parse_content_type or "application/pdf"
if attachment.parse_status == HWP_CONVERSION_PENDING_STATUS:
- # HWP binary conversion requires a separately sandboxed converter. Keep
- # the source pending rather than sending a non-PDF payload to NewsDOM.
+ # Binary HWP needs a separately sandboxed converter and must not be
+ # handed to the PDF or HWPX recognizers.
return RESULT_PENDING
+
+ if attachment.parse_status == HWPX_PENDING_STATUS:
+ try:
+ hwpx_bytes = decode_deferred_attachment_payload(
+ attachment.content,
+ attachment.parse_content_type or HWPX_PARSE_CONTENT_TYPE,
+ )
+ except ValueError as exc:
+ attachment.parse_status = HWPX_FAILED_STATUS
+ attachment.parse_error_code = "invalid_pending_payload"
+ logger.warning(
+ "HWPX attachment %s rejected before recognition: %s",
+ getattr(attachment, "id", "?"),
+ exc,
+ )
+ return RESULT_FAILED
+
+ try:
+ recognize_attachment_hwpx(
+ email=email,
+ attachment=attachment,
+ hwpx_bytes=hwpx_bytes,
+ source_record_uid=_attachment_source_record_uid(email, attachment),
+ )
+ except ValueError as exc:
+ attachment.parse_status = HWPX_FAILED_STATUS
+ attachment.parse_error_code = "recognition_failed"
+ logger.warning(
+ "HWPX attachment %s recognition failed: %s",
+ getattr(attachment, "id", "?"),
+ exc,
+ )
+ return RESULT_FAILED
+ return RESULT_RECOGNIZED
+
+ expected_content_type = attachment.parse_content_type or "application/pdf"
try:
deferred_bytes = decode_deferred_attachment_payload(
attachment.content,
@@ -266,29 +352,6 @@ async def process_pending_attachment(
)
return RESULT_FAILED
- if attachment.parse_status == HWPX_XML_PACKAGE_PENDING_STATUS:
- try:
- records = recognize_hwpx(
- hwpx_bytes=deferred_bytes,
- source_record_uid=f"attachment-{attachment.id}",
- display_name=attachment.filename or "",
- )
- except HwpxRecognitionError as exc:
- attachment.parse_status = PDF_DOM_RECOGNITION_FAILED_STATUS
- attachment.parse_error_code = "hwpx_recognition_failed"
- logger.warning(
- "HWPX attachment %s recognition failed: %s",
- getattr(attachment, "id", "?"),
- exc,
- )
- return RESULT_FAILED
- apply_hwpx_recognition_to_attachment(
- email=email,
- attachment=attachment,
- records=records,
- )
- return RESULT_RECOGNIZED
-
pdf_bytes = deferred_bytes
config = await config_resolver(session, email.organization_id)
@@ -308,7 +371,7 @@ async def process_pending_attachment(
attachment=attachment,
pdf_bytes=pdf_bytes,
config=config,
- source_record_uid=f"attachment-{attachment.id}",
+ source_record_uid=_attachment_source_record_uid(email, attachment),
request_fn=request_fn,
)
except NewsdomConfigurationError as exc:
@@ -427,13 +490,14 @@ async def _release_sweep_lease(session: AsyncSession) -> None:
class NewsdomRecognitionWorker:
- """Periodically recognize pending PDF attachments and workspace documents.
+ """Periodically recognize pending PDF/HWPX attachments and PDF documents.
Mirrors :class:`ReplySlaScheduler`: a jittered periodic loop, a PostgreSQL
advisory-lock lease so only one replica sweeps per cycle, and per-item
- error isolation. Items whose organization has no active NewsDOM provider are
- left pending (they recognize once a provider is configured); unusable
- payloads/responses are marked failed rather than parsed.
+ error isolation. HWPX work is deterministic/local. PDF items whose
+ organization has no active NewsDOM provider remain pending until a provider
+ is configured; unusable payloads/responses are marked failed rather than
+ parsed.
"""
def __init__(
@@ -506,8 +570,7 @@ async def _sweep(self) -> None:
lease = await _try_acquire_sweep_lease(session)
if lease is False:
logger.debug(
- "NewsDOM recognition sweep skipped: another replica holds "
- "the lease."
+ "Recognition sweep skipped: another replica holds the lease."
)
return
try:
@@ -533,26 +596,23 @@ async def _sweep_attachments(self, session: AsyncSession) -> None:
await session.commit()
if result != RESULT_PENDING:
logger.info(
- "NewsDOM attachment %s recognition result: %s",
+ "Attachment %s recognition result: %s",
attachment.id,
result,
)
except Exception:
await session.rollback()
logger.error(
- "NewsDOM attachment %s recognition raised.",
+ "Attachment %s recognition raised.",
getattr(attachment, "id", "?"),
exc_info=True,
)
def _pending_attachment_statement(self, after_id: int | None):
- """Build the next deterministic attachment batch query."""
+ """Build the next deterministic PDF/HWPX attachment batch query."""
statement = select(Attachment).where(
Attachment.parse_status.in_(
- (
- PDF_DOM_RECOGNITION_PENDING_STATUS,
- HWPX_XML_PACKAGE_PENDING_STATUS,
- )
+ (PDF_DOM_RECOGNITION_PENDING_STATUS, HWPX_PENDING_STATUS)
)
)
if after_id is not None:
diff --git a/backend/tests/test_hwpx_recognition.py b/backend/tests/test_hwpx_recognition.py
index 7a1048dc7..976f1b7ba 100644
--- a/backend/tests/test_hwpx_recognition.py
+++ b/backend/tests/test_hwpx_recognition.py
@@ -1,138 +1,261 @@
-"""Regression tests for bounded HWPX XML extraction."""
+"""Executable contracts for safe HWPX section-text recognition."""
+from __future__ import annotations
+
+import hashlib
import io
import zipfile
import pytest
-from services import hwpx_recognition as hwpx_module
-
-HwpxRecognitionError = hwpx_module.HwpxRecognitionError
-recognize_hwpx = hwpx_module.recognize_hwpx
+from services import hwpx_recognition as recognition
-def _package(
- section_xml: str,
- *,
- compression: int = zipfile.ZIP_DEFLATED,
- duplicate_section: bool = False,
-) -> bytes:
- """Build a package with the same members admitted by attachment parsing."""
- stream = io.BytesIO()
- with zipfile.ZipFile(stream, "w") as archive:
- archive.writestr("mimetype", "application/hwp+zip")
- archive.writestr("version.xml", "")
- archive.writestr("Contents/content.hpf", "")
- archive.writestr("Contents/section0.xml", section_xml, compress_type=compression)
- if duplicate_section:
- with pytest.warns(UserWarning, match="Duplicate name"):
- archive.writestr(
- "Contents/section0.xml",
- section_xml,
- compress_type=compression,
- )
- return stream.getvalue()
+_OPF_NS = "http://www.idpf.org/2007/opf/"
+_HP_NS = "http://www.owpml.org/owpml/2021/paragraph"
+_HS_NS = "http://www.owpml.org/owpml/2021/section"
-def test_recognize_hwpx_extracts_paragraphs_and_graph_records() -> None:
- payload = _package(
- ""
- "첫 문단"
- "둘째 문단"
- ""
+def _section_xml(*paragraphs: str) -> str:
+ """Return a minimal standards-shaped HWPX section XML document."""
+ rendered = "".join(
+ f'{text}'
+ for text in paragraphs
)
+ return (
+ f''
+ f"{rendered}"
+ )
+
- result = recognize_hwpx(
- hwpx_bytes=payload,
- source_record_uid="attachment-1",
- display_name="policy.hwpx",
+def _content_hpf(
+ *,
+ manifest: tuple[tuple[str, str], ...],
+ spine: tuple[str, ...],
+) -> str:
+ """Return a minimal OPF manifest/spine used by an HWPX package."""
+ manifest_xml = "".join(
+ f''
+ for item_id, href in manifest
+ )
+ spine_xml = "".join(f'' for item_id in spine)
+ return (
+ f'{manifest_xml}'
+ f"{spine_xml}"
)
- assert result.parse_text == "첫 문단\n\n둘째 문단"
- assert result.parse_result.content_type == "application/hwp+zip"
- assert len(result.parse_result.segments) == 2
- assert result.source_content_hash
+def _hwpx_package(
+ *,
+ sections: dict[str, str],
+ spine: tuple[str, ...],
+ manifest_hrefs: dict[str, str] | None = None,
+ include_version: bool = True,
+) -> bytes:
+ """Build a small HWPX package with explicit manifest and spine order."""
+ hrefs = manifest_hrefs or {
+ section_id: f"Contents/{section_id}.xml" for section_id in sections
+ }
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("mimetype", b"application/hwp+zip")
+ if include_version:
+ archive.writestr("version.xml", b'')
+ archive.writestr(
+ "Contents/content.hpf",
+ _content_hpf(
+ manifest=tuple((section_id, hrefs[section_id]) for section_id in sections),
+ spine=spine,
+ ),
+ )
+ for section_id, xml in sections.items():
+ archive.writestr(f"Contents/{section_id}.xml", xml)
+ return buffer.getvalue()
+
+
+def test_recognize_hwpx_follows_spine_and_preserves_paragraph_provenance() -> None:
+ """Read sections in OPF spine order and retain semantic source positions."""
+ payload = _hwpx_package(
+ sections={
+ "section0": _section_xml("첫 번째 구역", "둘째 문단"),
+ "section1": _section_xml("두 번째 구역"),
+ },
+ spine=("section1", "section0"),
+ )
-def test_recognize_hwpx_rejects_missing_sections() -> None:
- stream = io.BytesIO()
- with zipfile.ZipFile(stream, "w") as archive:
- archive.writestr("mimetype", "application/hwp+zip")
- archive.writestr("version.xml", "")
- archive.writestr("Contents/content.hpf", "")
+ records = recognition.recognize_hwpx_package(
+ payload,
+ filename="proposal.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-42",
+ )
- with pytest.raises(HwpxRecognitionError, match="no section"):
- recognize_hwpx(hwpx_bytes=stream.getvalue(), source_record_uid="attachment-2")
+ assert records.parse_text == "두 번째 구역\n\n첫 번째 구역\n\n둘째 문단"
+ assert records.parse_result.source_content_hash == hashlib.sha256(payload).hexdigest()
+ assert [segment.safe_text_content for segment in records.parse_result.segments] == [
+ "두 번째 구역",
+ "첫 번째 구역",
+ "둘째 문단",
+ ]
+ assert [segment.segment_path for segment in records.parse_result.segments] == [
+ "/document[1]/section[1]/paragraph[1]",
+ "/document[1]/section[2]/paragraph[1]",
+ "/document[1]/section[2]/paragraph[2]",
+ ]
+
+ changed_records = recognition.recognize_hwpx_package(
+ _hwpx_package(
+ sections={"section0": _section_xml("변경된 문서")},
+ spine=("section0",),
+ ),
+ filename="proposal.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-42",
+ )
+ assert changed_records.parse_result.nodes[0].content_node_uid != (
+ records.parse_result.nodes[0].content_node_uid
+ )
+ assert changed_records.parse_result.segments[0].content_segment_uid != (
+ records.parse_result.segments[0].content_segment_uid
+ )
-def test_recognize_hwpx_rejects_duplicate_sections() -> None:
- payload = _package(
- "text
",
- duplicate_section=True,
+def test_recognize_hwpx_rejects_missing_version_member() -> None:
+ """Revalidate the required version member before section recognition."""
+ payload = _hwpx_package(
+ sections={"section0": _section_xml("safe")},
+ spine=("section0",),
+ include_version=False,
)
- with pytest.raises(HwpxRecognitionError, match="duplicate"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-3")
+ with pytest.raises(ValueError, match="missing required identity"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="missing-version.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-47",
+ )
-def test_recognize_hwpx_rejects_entity_declarations_and_malformed_xml() -> None:
- entity_payload = _package(
- "]>&x;"
+@pytest.mark.parametrize("member_name", ("mimetype", "Contents/content.hpf"))
+def test_recognize_hwpx_normalizes_zip_read_failures(
+ monkeypatch: pytest.MonkeyPatch,
+ member_name: str,
+) -> None:
+ """Convert ZIP read implementation failures into bounded parse errors."""
+ payload = _hwpx_package(
+ sections={"section0": _section_xml("safe")},
+ spine=("section0",),
)
- with pytest.raises(HwpxRecognitionError, match="declarations"):
- recognize_hwpx(hwpx_bytes=entity_payload, source_record_uid="attachment-4")
+ original_read = zipfile.ZipFile.read
+
+ def broken_read(archive, member, *args, **kwargs):
+ if getattr(member, "filename", member) == member_name:
+ raise zipfile.BadZipFile("simulated read failure")
+ return original_read(archive, member, *args, **kwargs)
+
+ monkeypatch.setattr(zipfile.ZipFile, "read", broken_read)
- malformed_payload = _package("")
- with pytest.raises(HwpxRecognitionError, match="safely read"):
- recognize_hwpx(
- hwpx_bytes=malformed_payload,
- source_record_uid="attachment-5",
+ with pytest.raises(ValueError, match="could not be read"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="read-failure.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-48",
)
-def test_recognize_hwpx_rejects_unsupported_compression() -> None:
- if not hasattr(zipfile, "ZIP_BZIP2"):
- pytest.skip("Python zipfile has no BZIP2 support")
- payload = _package(
- "text
",
- compression=zipfile.ZIP_BZIP2,
+def test_paragraph_text_does_not_duplicate_nested_paragraphs() -> None:
+ """Nested paragraph nodes are skipped by the containing paragraph."""
+ nested_section = (
+ f''
+ "nested"
+ ""
+ )
+ payload = _hwpx_package(
+ sections={"section0": nested_section},
+ spine=("section0",),
)
- with pytest.raises(HwpxRecognitionError, match="compression"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-6")
+ records = recognition.recognize_hwpx_package(
+ payload,
+ filename="nested.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-49",
+ )
+ assert records.parse_text == "nested"
+ assert records.paragraph_count == 1
-def test_recognize_hwpx_enforces_section_and_total_xml_limits(monkeypatch) -> None:
- payload = _package("text
")
- monkeypatch.setattr(hwpx_module, "MAX_HWPX_SECTION_XML_BYTES", 1)
- with pytest.raises(HwpxRecognitionError, match="section exceeds"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-7")
- monkeypatch.setattr(hwpx_module, "MAX_HWPX_SECTION_XML_BYTES", 10_000)
- monkeypatch.setattr(hwpx_module, "MAX_HWPX_TOTAL_XML_BYTES", 1)
- with pytest.raises(HwpxRecognitionError, match="total size"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-8")
+def test_recognize_hwpx_rejects_manifest_path_traversal() -> None:
+ """Never resolve an OPF manifest href outside the HWPX package root."""
+ payload = _hwpx_package(
+ sections={"section0": _section_xml("safe")},
+ spine=("section0",),
+ manifest_hrefs={"section0": "../section0.xml"},
+ )
+ with pytest.raises(ValueError, match="unsafe manifest href"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="unsafe.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-43",
+ )
-def test_recognize_hwpx_rejects_changed_read_size(monkeypatch) -> None:
- payload = _package("text
")
- monkeypatch.setattr(zipfile.ZipFile, "read", lambda _archive, _info: b"")
- with pytest.raises(HwpxRecognitionError, match="size changed"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-9")
+def test_recognize_hwpx_bounds_expanded_xml_before_reading(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Reject a ZIP expansion budget violation before XML parsing."""
+ payload = _hwpx_package(
+ sections={"section0": _section_xml("x" * 256)},
+ spine=("section0",),
+ )
+ monkeypatch.setattr(recognition, "MAX_HWPX_XML_MEMBER_BYTES", 64)
+
+ with pytest.raises(ValueError, match="XML member exceeds"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="oversized.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-44",
+ )
-def test_recognize_hwpx_enforces_text_limit(monkeypatch) -> None:
- payload = _package("text
")
- monkeypatch.setattr(hwpx_module, "MAX_HWPX_TEXT_CHARS", 1)
+def test_recognize_hwpx_rejects_unsafe_xml_entities() -> None:
+ """Defused XML parsing must reject entity-bearing section payloads."""
+ dangerous_section = (
+ ']>'
+ f''
+ "&exfil;"
+ )
+ payload = _hwpx_package(
+ sections={"section0": dangerous_section},
+ spine=("section0",),
+ )
- with pytest.raises(HwpxRecognitionError, match="text exceeds"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-10")
+ with pytest.raises(ValueError, match="unsafe XML"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="entity.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-45",
+ )
-def test_recognize_hwpx_rejects_empty_text_and_non_string_xml_tags() -> None:
- payload = _package("
")
- with pytest.raises(HwpxRecognitionError, match="no readable"):
- recognize_hwpx(hwpx_bytes=payload, source_record_uid="attachment-11")
+def test_recognize_hwpx_requires_spine_referenced_section() -> None:
+ """Fail closed when OPF spine metadata cannot resolve a section member."""
+ payload = _hwpx_package(
+ sections={"section0": _section_xml("safe")},
+ spine=("missing-section",),
+ )
- assert hwpx_module._local_name(None) == ""
+ with pytest.raises(ValueError, match="spine item"):
+ recognition.recognize_hwpx_package(
+ payload,
+ filename="missing.hwpx",
+ source_kind="attachment",
+ source_record_uid="attachment-46",
+ )
diff --git a/backend/tests/test_hwpx_worker.py b/backend/tests/test_hwpx_worker.py
new file mode 100644
index 000000000..3a93fccd7
--- /dev/null
+++ b/backend/tests/test_hwpx_worker.py
@@ -0,0 +1,226 @@
+"""Contract tests for deferred HWPX background recognition.
+
+The attachment importer deliberately stores validated HWPX bytes as a pending
+base64 payload. These tests require the existing recognition worker to consume
+that pending state locally, without requiring a NewsDOM provider, while keeping
+failure states explicit and bounded.
+"""
+
+from __future__ import annotations
+
+import base64
+import io
+import zipfile
+
+import pytest
+from sqlalchemy.dialects import postgresql
+
+from db.models import Attachment, Email
+from services.newsdom_worker import (
+ RESULT_FAILED,
+ RESULT_RECOGNIZED,
+ NewsdomRecognitionWorker,
+ process_pending_attachment,
+)
+
+HWPX_PENDING_STATUS = "hwpx_xml_package_pending"
+HWPX_PARSED_STATUS = "hwpx_xml_package_parsed"
+HWPX_FAILED_STATUS = "hwpx_xml_package_failed"
+
+
+def _hwpx_payload(
+ *, include_section: bool = True, include_version: bool = True
+) -> bytes:
+ """Build one minimal standards-shaped HWPX package for worker tests."""
+
+ package = io.BytesIO()
+ with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_STORED) as archive:
+ archive.writestr("mimetype", b"application/hwp+zip")
+ # Parent #1353 admission requires version.xml before a payload can be pending.
+ if include_version:
+ archive.writestr("version.xml", b'')
+ archive.writestr(
+ "Contents/content.hpf",
+ b"""
+
+
+
+
+
+
+
+
+""",
+ )
+ if include_section:
+ archive.writestr(
+ "Contents/section0.xml",
+ b"""
+
+ Quarterly decision record
+ Approve the next action.
+
+""",
+ )
+ return package.getvalue()
+
+
+def _pending_hwpx_attachment(payload: bytes) -> Attachment:
+ """Create an in-memory HWPX attachment with its owning email relationship."""
+
+ email = Email()
+ email.organization_id = "org-hwpx"
+ attachment = Attachment(
+ id=73,
+ filename="decision.hwpx",
+ content=base64.b64encode(payload).decode("ascii"),
+ content_type="application/hwp+zip",
+ parse_content_type="application/hwp+zip",
+ parser_key="hwpx",
+ parse_status=HWPX_PENDING_STATUS,
+ )
+ email.attachments.append(attachment)
+ return attachment
+
+
+async def _must_not_resolve_provider(*_args, **_kwargs):
+ """Fail when deterministic HWPX recognition tries to resolve NewsDOM."""
+
+ raise AssertionError("HWPX recognition must not require a NewsDOM provider")
+
+
+async def _must_not_call_newsdom(**_kwargs):
+ """Fail when deterministic HWPX recognition reaches the NewsDOM sidecar."""
+
+ raise AssertionError("HWPX recognition must not call the NewsDOM sidecar")
+
+
+@pytest.mark.asyncio
+async def test_pending_hwpx_attachment_is_recognized_without_provider() -> None:
+ """A pending HWPX package becomes searchable text plus graph provenance."""
+
+ attachment = _pending_hwpx_attachment(_hwpx_payload())
+ attachment.parse_content_type = ""
+
+ result = await process_pending_attachment(
+ session=object(),
+ attachment=attachment,
+ config_resolver=_must_not_resolve_provider,
+ request_fn=_must_not_call_newsdom,
+ )
+
+ assert result == RESULT_RECOGNIZED
+ assert attachment.parse_status == HWPX_PARSED_STATUS
+ assert attachment.parse_error_code is None
+ assert attachment.parse_content_type == "application/hwp+zip"
+ assert attachment.parser_key == "hwpx"
+ assert attachment.content == (
+ "Quarterly decision record\n\nApprove the next action."
+ )
+ assert [segment.safe_text_content for segment in attachment.content_segments] == [
+ "Quarterly decision record",
+ "Approve the next action.",
+ ]
+ assert attachment.content_nodes
+ assert attachment.content_nodes[0].source_record_uid.startswith("attachment:")
+ assert attachment.content_nodes[0].source_record_uid != "attachment-73"
+
+
+@pytest.mark.asyncio
+async def test_pending_hwpx_attachment_revalidates_retained_bytes() -> None:
+ """Tampered retained bytes fail closed before any provider or XML work."""
+
+ attachment = _pending_hwpx_attachment(b"not-a-zip")
+
+ result = await process_pending_attachment(
+ session=object(),
+ attachment=attachment,
+ config_resolver=_must_not_resolve_provider,
+ request_fn=_must_not_call_newsdom,
+ )
+
+ assert result == RESULT_FAILED
+ assert attachment.parse_status == HWPX_FAILED_STATUS
+ assert attachment.parse_error_code == "invalid_pending_payload"
+ assert attachment.content_nodes == []
+ assert attachment.content_segments == []
+
+
+@pytest.mark.asyncio
+async def test_pending_hwpx_attachment_records_recognizer_failure() -> None:
+ """A valid HWPX identity with a broken spine never masquerades as parsed."""
+
+ attachment = _pending_hwpx_attachment(_hwpx_payload(include_section=False))
+
+ result = await process_pending_attachment(
+ session=object(),
+ attachment=attachment,
+ config_resolver=_must_not_resolve_provider,
+ request_fn=_must_not_call_newsdom,
+ )
+
+ assert result == RESULT_FAILED
+ assert attachment.parse_status == HWPX_FAILED_STATUS
+ assert attachment.parse_error_code == "recognition_failed"
+ assert attachment.content_nodes == []
+ assert attachment.content_segments == []
+
+
+@pytest.mark.asyncio
+async def test_pending_hwpx_attachment_revalidates_version_member() -> None:
+ """A retained package missing version.xml cannot become parsed."""
+ attachment = _pending_hwpx_attachment(_hwpx_payload(include_version=False))
+
+ result = await process_pending_attachment(
+ session=object(),
+ attachment=attachment,
+ config_resolver=_must_not_resolve_provider,
+ request_fn=_must_not_call_newsdom,
+ )
+
+ assert result == RESULT_FAILED
+ assert attachment.parse_status == HWPX_FAILED_STATUS
+ assert attachment.parse_error_code == "invalid_pending_payload"
+
+
+@pytest.mark.asyncio
+async def test_orphan_pending_hwpx_attachment_uses_hwpx_failure_status() -> None:
+ """An orphan HWPX row fails visibly without being mislabeled as PDF."""
+
+ attachment = Attachment(
+ id=74,
+ filename="orphan.hwpx",
+ content=base64.b64encode(_hwpx_payload()).decode("ascii"),
+ content_type="application/hwp+zip",
+ parse_content_type="application/hwp+zip",
+ parser_key="hwpx",
+ parse_status=HWPX_PENDING_STATUS,
+ )
+
+ result = await process_pending_attachment(
+ session=object(),
+ attachment=attachment,
+ config_resolver=_must_not_resolve_provider,
+ request_fn=_must_not_call_newsdom,
+ )
+
+ assert result == RESULT_FAILED
+ assert attachment.parse_status == HWPX_FAILED_STATUS
+ assert attachment.parse_error_code == "orphan_attachment"
+
+
+def test_worker_selects_pdf_and_hwpx_pending_attachments() -> None:
+ """The bounded sweep must include both deferred attachment families."""
+
+ worker = NewsdomRecognitionWorker(batch_limit=7)
+ statement = worker._pending_attachment_statement(None)
+ sql = str(
+ statement.compile(
+ dialect=postgresql.dialect(),
+ compile_kwargs={"literal_binds": True},
+ )
+ )
+
+ assert "pdf_dom_recognition_pending" in sql
+ assert HWPX_PENDING_STATUS in sql
+ assert "LIMIT 7" in sql
diff --git a/backend/tests/test_newsdom_worker.py b/backend/tests/test_newsdom_worker.py
index fd5bcb18b..f904d37df 100644
--- a/backend/tests/test_newsdom_worker.py
+++ b/backend/tests/test_newsdom_worker.py
@@ -29,6 +29,8 @@
RESULT_FAILED = newsdom_worker_module.RESULT_FAILED
RESULT_PENDING = newsdom_worker_module.RESULT_PENDING
RESULT_RECOGNIZED = newsdom_worker_module.RESULT_RECOGNIZED
+HWPX_FAILED_STATUS = newsdom_worker_module.HWPX_FAILED_STATUS
+HWPX_PARSED_STATUS = newsdom_worker_module.HWPX_PARSED_STATUS
process_pending_attachment = newsdom_worker_module.process_pending_attachment
process_pending_document = newsdom_worker_module.process_pending_document
@@ -101,7 +103,13 @@ def _hwpx_payload(*, include_text: bool = True) -> bytes:
compress_type=zipfile.ZIP_STORED,
)
archive.writestr("version.xml", "")
- archive.writestr("Contents/content.hpf", "")
+ archive.writestr(
+ "Contents/content.hpf",
+ """
+
+
+ """,
+ )
text = (
"보안 정책"
"원본 근거를 보존합니다."
@@ -416,7 +424,7 @@ async def test_hwpx_pending_attachment_is_extracted_and_graph_landed():
)
assert result == RESULT_RECOGNIZED
- assert attachment.parse_status == "parsed"
+ assert attachment.parse_status == HWPX_PARSED_STATUS
assert attachment.parser_key == "hwpx"
assert attachment.content == "보안 정책\n\n원본 근거를 보존합니다."
assert len(attachment.content_nodes) == 4
@@ -455,8 +463,8 @@ async def test_hwpx_pending_attachment_records_safe_failure_for_empty_text():
result = await process_pending_attachment(session=object(), attachment=attachment)
assert result == RESULT_FAILED
- assert attachment.parse_status == PDF_DOM_RECOGNITION_FAILED_STATUS
- assert attachment.parse_error_code == "hwpx_recognition_failed"
+ assert attachment.parse_status == HWPX_FAILED_STATUS
+ assert attachment.parse_error_code == "recognition_failed"
@pytest.mark.asyncio
diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md
index 3183cc342..e7d9aa489 100644
--- a/docs/doctoring/hwp-hwpx-attachment-recognition.md
+++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md
@@ -1,5 +1,14 @@
# HWP and HWPX attachment-recognition boundary
+## Status
+
+This document separates protected-`develop` truth from active pull-request work.
+The HWP/HWPX deferred-import contract is owned by active parent PR #1353. The
+ordered HWPX section recognizer and its production worker handoff are owned by
+stacked active PR #1373. Neither capability is shipped from protected `develop`
+until its exact integrated head passes the live review, CI, security, coverage,
+provenance, and branch-governance gates and is merged.
+
## Decision
Naruon recognizes HWPX and HWP attachments before OCR, XML extraction, or LLM
@@ -8,8 +17,13 @@ classifies the parser family, applies bounded signature checks, retains exact
source bytes as a base64 deferred-recognition payload, and records a stable
pending or rejection status.
-This keeps email import deterministic and evidence-preserving while later
-sandboxed workers perform heavier extraction.
+The HWPX worker then performs a deterministic local recognition stage rather
+than sending package bytes to an external model or NewsDOM provider. It repeats
+package validation, resolves body sections through `Contents/content.hpf`,
+parses only bounded selected XML with entity/DTD defenses, and lands ordered
+paragraph text plus source-bound content-graph provenance. This keeps email
+import deterministic and evidence-preserving while making recognized Korean
+enterprise document text searchable without model dependence.
## Active deferred-import boundary — PR #1353
@@ -18,9 +32,9 @@ sandboxed workers perform heavier extraction.
- HWPX content types are recognized as deferred OWPML XML packages.
- HWPX bytes must be a bounded single-disk ZIP package with one unambiguous
`mimetype` member whose exact content is `application/hwp+zip`, a `version.xml`
- member, and at least one canonical `Contents/sectionN.xml` member. Package
- manifest presence alone is not sufficient admission evidence because the
- recognition worker cannot materialize a sectionless package.
+ member, and at least one canonical `Contents/sectionN.xml` member. A package
+ manifest by itself is not sufficient admission evidence because the worker
+ cannot materialize a sectionless package.
- The importer validates the end-of-central-directory entry count and directory
size before Python materializes ZIP members, then bounds aggregate member-name
bytes and the tiny `mimetype` payload before reading it.
@@ -28,8 +42,8 @@ sandboxed workers perform heavier extraction.
members, unsupported ZIP structures, malformed ZIP metadata, sectionless
packages, and exceeded limits fail closed as `invalid_hwpx_payload`.
- Import does not decompress document sections, extract files, execute active
- content, or fetch external resources. Later workers must repeat path,
- compression, XML, resource, and expansion-ratio validation before extraction.
+ content, or fetch external resources. Later workers repeat path, XML,
+ encryption, resource, and expansion validation before extraction.
- `.hwp` files with generic binary MIME types are resolved to the HWP parser
family.
- HWP bytes must carry both the OLE Compound File container signature and the
@@ -47,27 +61,82 @@ sandboxed workers perform heavier extraction.
and lands paragraph text with stable content-graph provenance. The worker
never executes package content or follows external resources.
+## Active HWPX semantic-recognition boundary — PR #1373
+
+The current bounded vertical slice consumes only `hwpx_xml_package_pending`
+email attachments. It reuses Naruon's existing leased background-recognition
+worker instead of creating another scheduler or service authority.
+
+1. The worker decodes the retained base64 bytes with the HWPX content-family
+ validator. Corrupted or family-mismatched retained payloads become
+ `hwpx_xml_package_failed` with `invalid_pending_payload` before XML parsing.
+2. `mimetype` and `Contents/content.hpf` are mandatory. The worker rejects
+ duplicate paths, encrypted entries, traversal paths, unsafe manifest hrefs,
+ unresolved or repeated spine targets, and XML that violates the defused
+ parser boundary.
+3. Manifest identity resolves each spine item and the spine defines section
+ reading order. Only `Contents/sectionN.xml` targets are accepted in this
+ slice.
+4. Each selected XML member is bounded before decompression/read, and the
+ selected `content.hpf` plus section XML expansion total is bounded.
+5. Paragraphs are emitted in section/document order into the existing content
+ graph. The graph UIDs remain bound to SHA-256 of the exact original HWPX
+ source bytes rather than a lossy text reserialization.
+6. Recognition succeeds without a NewsDOM/provider configuration. Provider
+ resolution and network calls remain PDF-only behavior.
+7. Orphan attachments and parser failures remain visible failure states; no
+ error path is reported as parsed.
+8. The production sweep selects PDF and HWPX pending rows together with the
+ existing bounded, cursor-based, leased batch semantics so an HWPX attachment
+ cannot remain permanently invisible to the running worker.
+
+This slice deliberately reuses the existing `PdfDomSection`/`parse_pdf_dom`
+graph construction primitive as a format-neutral document→section→paragraph
+builder. That reuse does not assert that HWPX is PDF or transfer PDF parsing
+semantics; the recognizer supplies HWPX-derived ordered sections and the exact
+HWPX source hash.
+
+## Standards confirmation
+
+The Korean national standards registry identifies KS X 6101, *Open
+Word-Processor Markup Language (OWPML) document structure*, and records its
+latest confirmation/revision date as 2024-10-30. Hancom's current format
+material states that HWPX follows KS X 6101/OWPML and is a ZIP-packaged XML
+format designed for machine-readable document content. Hancom's HWPX parsing
+guidance also maps body XML to the OWPML body/section/paragraph schemas. The
+worker therefore treats package and XML structure as deterministic document
+evidence, not as an LLM interpretation target.
+
+No psychometric/statistical or model-orchestration claim is introduced by this
+slice, so peer-reviewed model evidence is not a gating dependency here. The
+material authority is the current national standard plus the format owner's
+primary technical documentation.
+
## HWPX resource bounds
-| Boundary | Current import limit | Purpose |
-| --- | ---: | --- |
-| Complete deferred source | 64 MiB | Align deferred retention with the email import transport while bounding memory and database payload growth. |
-| ZIP entry count | 4,096 | Bound member-object and traversal work. |
-| Central-directory bytes | 4 MiB | Bound metadata parsing before member materialization. |
-| Aggregate decoded member-name bytes | 1 MiB | Prevent path/name metadata amplification. |
-| `mimetype` uncompressed bytes | 128 bytes | Keep signature validation deterministic and non-expansive. |
+| Boundary | Current bound | Applied at | Purpose |
+| --- | ---: | --- | --- |
+| Complete deferred source | 64 MiB | import + deferred decode | Align deferred retention with the email import transport while bounding memory and database payload growth. |
+| ZIP entry count | 4,096 | import + worker | Bound member-object and traversal work. |
+| Central-directory bytes | 4 MiB | import | Bound metadata parsing before member materialization. |
+| Aggregate decoded member-name bytes | 1 MiB | import + worker | Prevent path/name metadata amplification. |
+| `mimetype` uncompressed bytes | 128 bytes | import + worker | Keep signature validation deterministic and non-expansive. |
+| One selected XML member | 4 MiB | worker | Bound decompression and parser memory per selected member. |
+| Selected XML total | 16 MiB | worker | Bound aggregate semantic-recognition expansion. |
-These are admission limits, not statements about the maximum document Hancom
-Office can create. An operator may change them only with reviewed capacity and
-security evidence. The recognition step deliberately rejects ZIP64 or multi-disk
-packages rather than widening a low-cost email-import boundary.
+These are product admission/worker limits, not statements about the maximum
+document Hancom Office can create. An operator may change them only with
+reviewed capacity and security evidence. The import boundary deliberately
+rejects ZIP64 or multi-disk packages rather than widening low-cost email import.
-## Test-first repair evidence
+## Test-first evidence
+
+### Import hardening — parent PR #1353
The initial HWPX slice accepted a ZIP by member names alone. A generic ZIP could
-therefore imitate `mimetype`, `version.xml`, and section paths without carrying
-the HWPX signature, while a small source file could still devote most of its
-bytes to a very large central directory.
+therefore imitate HWPX paths without carrying the HWPX signature, while a small
+source file could still devote most of its bytes to a very large central
+directory.
Commit `4b51240eb8521459ef622e49bd463a1a6d783288` added failing public-boundary
regressions for wrong and duplicate `mimetype` members, entry count,
@@ -75,12 +144,11 @@ central-directory bytes, aggregate name bytes, and signature-member bytes.
Commit `b737ae83c94ee8a5aaf9c22a8239056e26ffe029` then implemented the bounded
end-of-central-directory preflight and exact signature validation.
-A later review found that import admission still accepted a package with a
-manifest but no section XML, while the recognition worker must reject that same
-package because there is no materializable `Contents/sectionN.xml`. RED commit
-`44a268b988f9a3092368bd774a26582647e319a9` adds the manifest-only regression;
-causal fix `4281904b438ac50c2d6c40d14207119c383227a8` requires section evidence at
-import admission so the queue and worker share one fail-closed boundary.
+A later review found that import admission still accepted a manifest-only HWPX
+package that the worker must reject because no canonical section exists. RED
+commit `44a268b988f9a3092368bd774a26582647e319a9` adds that public-boundary
+regression; causal fix `4281904b438ac50c2d6c40d14207119c383227a8` requires
+section evidence before deferred queue admission.
The initial HWP slice likewise admitted any OLE Compound File if the caller
supplied an HWP extension or media type. Commit
@@ -91,36 +159,68 @@ FileHeader identity marker as a second admission signal; commit
`c8837fb00d74bd4ddc3152e0fe793e71f9e1f41f` aligned the positive fixture with
that real contract.
+### Ordered recognition and worker handoff — PR #1373
+
+- `84662ac7cf359455c59d37b54f201133558e9097` specifies OPF-spine ordering,
+ path/provenance, package traversal, XML expansion, unsafe-XML, and unresolved
+ spine behavior before the recognizer exists.
+- `66d3fd336c1cfaf37691e238c8ac3481b7eb2d56` implements the bounded HWPX
+ recognizer.
+- `ef8e990f2a88c861bd0f9135861e040a30aff8cc` specifies the production worker
+ handoff: local recognition without a provider, retained-byte revalidation,
+ failure visibility, content-graph landing, and pending-row selection.
+- `944a5303b814171b1f12553d9fe45d75a416440c` wires the HWPX pending state into
+ the existing leased recognition worker and preserves the PDF path.
+- `fdf157dd675f8ca91a248f801c2c6e4c75e0732f` and
+ `fbc7b4637e31ddf421af4aa5d2d94ae24e970e9f` cover parser-family orphan status
+ and canonical MIME fallback branches.
+
Hosted exact-head CI, security, coverage, and review evidence remains
-authoritative for merge.
+authoritative for merge; the commit lineage above is implementation
+traceability, not a substitute for current-head gates.
+
+## Requirement traceability
+
+| Requirement | Production owner | Test evidence | Maturity |
+| --- | --- | --- | --- |
+| Bounded HWPX admission and exact source retention | `backend/services/attachment_parser.py` | `test_attachment_parser*.py` | Active parent PR #1353 |
+| OPF manifest/spine ordered HWPX paragraph recognition | `backend/services/hwpx_recognition.py` | `test_hwpx_recognition.py` | Active stacked PR #1373 |
+| Deferred HWPX worker selection and local execution | `backend/services/newsdom_worker.py` | `test_hwpx_worker.py` | Active stacked PR #1373 |
+| Parsed attachment text + graph provenance | shared content-graph landing path | HWPX worker + recognizer tests | Active stacked PR #1373 |
+| Binary HWP conversion | future sandboxed converter | none yet | Planned / out of this slice |
+| HWPX tables, images, layout fidelity | future bounded recognizers | none yet | Planned / out of this slice |
+| Protected-`develop` shipped HWP/HWPX recognition | protected branch | integrated release gates | Not yet shipped |
## Status codes
-| Parser family | Pending status | Rejection status |
+| Parser family | Pending status | Parsed/failed state |
| --- | --- | --- |
-| PDF | `pdf_dom_recognition_pending` | `invalid_pdf_payload` |
-| HWPX | `hwpx_xml_package_pending` | `invalid_hwpx_payload` |
-| HWP | `hwp_conversion_pending` | `invalid_hwp_payload` |
+| PDF | `pdf_dom_recognition_pending` | existing NewsDOM parsed/failed states |
+| HWPX | `hwpx_xml_package_pending` | `hwpx_xml_package_parsed` / `hwpx_xml_package_failed` |
+| HWP | `hwp_conversion_pending` | converter not implemented in this slice |
## Out of scope
-This slice does not implement embedded image recognition, table reconstruction,
-HWP binary conversion, OCR, or LLM/VLM interpretation. HWPX paragraph
-extraction is implemented in the bounded worker; richer layout reconstruction
-belongs to a later worker-backed pipeline from the evidence-based workspace
-epic.
+This slice does not reconstruct HWPX tables, images, charts, layout, styles, or
+embedded objects; convert binary HWP; perform OCR; fetch external resources; or
+call LLM/VLM providers. Those require separately bounded workers and acceptance
+evidence. The worker is not a general ZIP/XML extraction service.
## Safety and buyer value
Korean enterprise mailboxes often carry HWP and HWPX evidence. Treating those
attachments as opaque unsupported binaries breaks context synthesis, search
-coverage, and auditability. Treating them as text or passing them directly to an
-LLM is also unsafe. This slice gives the product an auditable middle state: the
-source bytes are preserved, the file family is explicit, and follow-on workers
-can proceed without losing provenance.
+coverage, and auditability. Treating them as unbounded XML/ZIP input or passing
+source bytes directly to an LLM is also unsafe. The active vertical slice gives
+the product an auditable path from retained source bytes to ordered searchable
+paragraphs and exact-source provenance while keeping failure states explicit.
## References
+Korean Agency for Technology and Standards. (2024, October 30). *KS X 6101:
+Open Word-Processor Markup Language (OWPML) document structure*. e-Nara Standard
+Certification. https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do?ksNo=KSX6101&menuId=503&tmprKsNo=KSX6101&topMenuId=502
+
Hancom Inc. (n.d.). *HWP binary format and HWPML document format*. Hancom Support.
https://www.hancom.com/support/downloadCenter/hwpOwpml
@@ -133,6 +233,12 @@ https://tech.hancom.com/%ED%95%9C-%EA%B8%80-%EB%AC%B8%EC%84%9C-%ED%8C%8C%EC%9D%B
Hancom Tech. (2025b, February 26). *HWPX format structure*.
https://tech.hancom.com/hwpxformat/
+Hancom Tech. (2025c). *Parsing HWPX format with Python (Part 1)*.
+https://tech.hancom.com/python-hwpx-parsing-1/
+
+Hancom Tech. (2025d). *Parsing HWPX format with Python (Part 2)*.
+https://tech.hancom.com/python-hwpx-parsing-2/
+
PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*.
https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT