From a594e44bc58fc404ca8537b54925defa5dea00ee Mon Sep 17 00:00:00 2001
From: xcosmosbox <2162381070@qq.com>
Date: Tue, 30 Jun 2026 00:15:18 +0800
Subject: [PATCH] feat(parser): add support for XML file parsing
---
.../document_parser/formats/xml/parser.py | 221 +++++++++++++++++
.../orchestration/format_adapters.py | 20 ++
.../orchestration/format_router.py | 5 +
.../contract/test_xml_parser_contract.py | 226 ++++++++++++++++++
4 files changed, 472 insertions(+)
create mode 100644 apps/worker/app/services/document_parser/formats/xml/parser.py
create mode 100644 apps/worker/tests/contract/test_xml_parser_contract.py
diff --git a/apps/worker/app/services/document_parser/formats/xml/parser.py b/apps/worker/app/services/document_parser/formats/xml/parser.py
new file mode 100644
index 000000000..bd880c606
--- /dev/null
+++ b/apps/worker/app/services/document_parser/formats/xml/parser.py
@@ -0,0 +1,221 @@
+# pyright: reportArgumentType=false, reportAttributeAccessIssue=false, reportCallIssue=false, reportReturnType=false
+"""XML document parser.
+
+Converts .xml files into markdown-like text lines by recursively walking
+the XML element tree with Python's built-in xml.etree.ElementTree, then
+delegates to the standard parse_md() pipeline for hierarchy reconstruction,
+heading detection, and LLM-based enrichment.
+
+The conversion is intentionally lightweight — complex XML schemas and
+namespaces are ignored in favor of text extraction. Element tag names
+are used as lightweight structural hints.
+"""
+
+from xml.etree import ElementTree
+
+
+def parse_xml(
+ output_dir: str,
+ source_type: str,
+ file_path: str,
+ base_llm_paras=None,
+ relative_root: str | None = None,
+):
+ """Parse an XML file into a hierarchical document DataFrame.
+
+ Walks the XML element tree to extract text content from all elements,
+ using tag names to create markdown-like headings for structural
+ containers. The extracted lines are then fed through parse_md() for
+ heading detection, hierarchy reconstruction, and LLM enrichment —
+ the same pipeline used by the text and HTML adapters.
+
+ Args:
+ output_dir: Full output directory path for parsed artifacts.
+ source_type: Source type label (always "xml" from the adapter).
+ file_path: Absolute path to the .xml file on disk.
+ base_llm_paras: LLM parameter dict for summary enrichment.
+ relative_root: Root path segment for hierarchical path construction.
+
+ Returns:
+ pd.DataFrame with columns [path, content, type, summary, keywords]
+ representing the parsed document hierarchy.
+ """
+ from app.services.common.file_loading import load_file_bytes
+ from app.services.document_parser.formats.markdown.parser import parse_md
+
+ # Load and decode the XML file as UTF-8 text.
+ xml_bytes = load_file_bytes(file_path, file_url="")
+ xml_text = xml_bytes.decode("utf-8")
+
+ # Convert XML document into markdown-like text lines via recursive
+ # ElementTree traversal. We keep this lightweight — no schema
+ # awareness, no namespace handling beyond what ElementTree provides.
+ md_lines = _xml_to_md_lines(xml_text)
+
+ # Delegate to the standard markdown parsing pipeline.
+ parsed_df = parse_md(
+ output_dir,
+ source_type=source_type,
+ md_lines=md_lines,
+ base_llm_paras=base_llm_paras,
+ relative_root=relative_root,
+ )
+ return parsed_df
+
+
+def _xml_to_md_lines(xml_text: str) -> list[str]:
+ """Convert XML document into markdown-like text lines.
+
+ Parses the XML string with ElementTree, then recursively walks
+ the element tree to extract text content. Container-like elements
+ (based on common naming patterns) get heading-style prefixes;
+ leaf elements produce plain text lines.
+
+ Args:
+ xml_text: Raw XML document as a UTF-8 string.
+
+ Returns:
+ List of text lines suitable for the parse_md() pipeline.
+ """
+ try:
+ root = ElementTree.fromstring(xml_text)
+ except ElementTree.ParseError:
+ # If the XML is malformed, return the raw text as fallback so
+ # parse_md() can still attempt to extract meaningful content.
+ return xml_text.splitlines()
+
+ lines: list[str] = []
+ _walk_xml_elements(root, lines, depth=0)
+
+ # Strip trailing blank lines.
+ while lines and not lines[-1].strip():
+ lines.pop()
+
+ return lines
+
+
+def _walk_xml_elements(
+ element: ElementTree.Element,
+ lines: list[str],
+ depth: int = 0,
+) -> None:
+ """Recursively walk XML elements, appending markdown-like lines.
+
+ This is the core extraction loop. It processes each XML element:
+ - Elements with only text content (leaf elements) → text line.
+ - Elements with child elements → recurse, optionally emitting a
+ heading line for container-like tag names.
+ - Mixed content (text + children) → text line then recurse.
+
+ Tag names that look like section/document containers (section,
+ chapter, article, item, entry, record, document, part, module,
+ component, chapter, topic) get a heading-style prefix based on
+ nesting depth.
+
+ Args:
+ element: An xml.etree.ElementTree Element.
+ lines: Accumulator list for output lines.
+ depth: Current nesting depth (controls heading level).
+ """
+ tag = _local_name(element.tag)
+ text = (element.text or "").strip()
+
+ children = list(element)
+
+ if not children:
+ # ── Leaf element: emit tag as heading + text ───────────
+ if text:
+ heading = _tag_to_heading(tag, depth)
+ if heading:
+ lines.append(heading)
+ lines.append(text)
+ return
+
+ # ── Container element: optional heading, then recurse ──────
+ if text:
+ # Mixed content: element has both direct text and children.
+ heading = _tag_to_heading(tag, depth)
+ if heading:
+ lines.append(heading)
+ lines.append(text)
+ elif _is_container_tag(tag):
+ # Pure container: emit a heading for structural navigation.
+ heading = _tag_to_heading(tag, depth)
+ if heading:
+ lines.append(heading)
+
+ for child in children:
+ _walk_xml_elements(child, lines, depth + 1)
+
+ # Emit tail text (text after the closing tag).
+ tail = (element.tail or "").strip()
+ if tail:
+ lines.append(tail)
+
+
+def _local_name(tag: str) -> str:
+ """Strip XML namespace from a tag, returning just the local name.
+
+ ElementTree represents namespaced tags as '{uri}localname';
+ this extracts only the 'localname' portion for cleaner output.
+
+ Args:
+ tag: Full ElementTree tag string, possibly with namespace.
+
+ Returns:
+ The local part of the tag name, lowercased.
+ """
+ if "}" in tag:
+ return tag.split("}", 1)[1].lower()
+ return tag.lower()
+
+
+def _is_container_tag(tag: str) -> bool:
+ """Return True if the tag name looks like a document structure container.
+
+ Heuristic based on common XML vocabulary patterns. This is
+ intentionally simple — we match against a fixed set of known
+ container tag names rather than trying to infer structure from
+ the schema.
+
+ Args:
+ tag: Lowercased local tag name.
+
+ Returns:
+ True if the tag is recognized as a structural container.
+ """
+ _container_tags = frozenset({
+ "section", "chapter", "article", "item", "entry",
+ "record", "document", "part", "module", "component",
+ "topic", "group", "block", "segment", "division",
+ "body", "header", "footer", "sidebar", "content",
+ "abstract", "description", "summary", "details",
+ })
+ return tag in _container_tags
+
+
+def _tag_to_heading(tag: str, depth: int) -> str | None:
+ """Convert a tag name and depth into a markdown heading line.
+
+ Container tags at depth 0–1 get h1/h2; deeper nesting produces
+ progressively lower heading levels, clamped at h6.
+
+ Args:
+ tag: Lowercased local tag name.
+ depth: Nesting depth in the XML tree.
+
+ Returns:
+ A markdown heading string like '## Section Title', or None
+ if the tag should not produce a heading.
+ """
+ if not _is_container_tag(tag):
+ return None
+
+ # Map depth to heading level: depth 0 → h1, depth 1 → h2, etc.
+ # Clamp to h6 maximum.
+ level = min(depth + 1, 6)
+ prefix = "#" * level
+
+ # Use a human-readable version of the tag as the heading text.
+ label = tag.replace("_", " ").title()
+ return f"{prefix} {label}"
diff --git a/apps/worker/app/services/document_parser/orchestration/format_adapters.py b/apps/worker/app/services/document_parser/orchestration/format_adapters.py
index e6af2f8e5..cef91b6d7 100644
--- a/apps/worker/app/services/document_parser/orchestration/format_adapters.py
+++ b/apps/worker/app/services/document_parser/orchestration/format_adapters.py
@@ -179,6 +179,26 @@ def parse(self, session: ParseSession) -> ParseOutput:
return ParseOutput(output_dir=session.full_output_dir, parsed_df=None)
+@dataclass(frozen=True)
+class XmlParseAdapter:
+ """Adapter that parses .xml files through the markdown pipeline.
+ XML content is recursively walked to extract text from elements,
+ with tag names mapped to markdown headings where appropriate.
+ The extracted lines are then routed through parse_md()."""
+ document_format: object
+
+ def parse(self, session: ParseSession) -> ParseOutput:
+ from app.services.document_parser.formats.xml.parser import parse_xml
+ parsed_df = parse_xml(
+ session.full_output_dir,
+ source_type="xml",
+ file_path=session.file_full_path,
+ base_llm_paras=session.base_llm_paras,
+ relative_root=session.relative_root,
+ )
+ return ParseOutput(output_dir=session.full_output_dir, parsed_df=parsed_df)
+
+
def _parse_docx_path(
docx_path: str,
session: ParseSession,
diff --git a/apps/worker/app/services/document_parser/orchestration/format_router.py b/apps/worker/app/services/document_parser/orchestration/format_router.py
index b94cf2787..75fa91481 100644
--- a/apps/worker/app/services/document_parser/orchestration/format_router.py
+++ b/apps/worker/app/services/document_parser/orchestration/format_router.py
@@ -22,6 +22,7 @@ class DocumentFormat(str, Enum):
PPTX = "pptx"
MARKDOWN = "markdown"
JSON = "json"
+ XML = "xml"
SUPPORTED_FILE_TYPES: tuple[str, ...] = (
@@ -37,6 +38,7 @@ class DocumentFormat(str, Enum):
".xlsx",
".pptx",
".md",
+ ".xml",
".json",
)
@@ -65,6 +67,8 @@ def resolve_document_format(file_path: str) -> DocumentFormat:
return DocumentFormat.MARKDOWN
if extension == ".json":
return DocumentFormat.JSON
+ if extension == ".xml":
+ return DocumentFormat.XML
raise ValidationException(
user_message=f"Unsupported file type: {extension}",
@@ -89,6 +93,7 @@ def get_document_parse_adapter(document_format: DocumentFormat) -> DocumentParse
DocumentFormat.XLSX: format_adapters.XlsxParseAdapter(document_format),
DocumentFormat.PPTX: format_adapters.PptxParseAdapter(document_format),
DocumentFormat.MARKDOWN: format_adapters.MarkdownParseAdapter(document_format),
+ DocumentFormat.XML: format_adapters.XmlParseAdapter(document_format),
DocumentFormat.JSON: format_adapters.JsonParseAdapter(document_format),
}
return adapter_by_format[document_format]
diff --git a/apps/worker/tests/contract/test_xml_parser_contract.py b/apps/worker/tests/contract/test_xml_parser_contract.py
new file mode 100644
index 000000000..8319c192d
--- /dev/null
+++ b/apps/worker/tests/contract/test_xml_parser_contract.py
@@ -0,0 +1,226 @@
+"""Contract tests for the XML format parser.
+
+Verifies that .xml files are correctly routed through the
+XmlParseAdapter and produce valid ParseOutput with the expected
+DataFrame contract: columns [path, content, type, summary, keywords].
+
+Test pattern follows test_html_parser_contract.py and
+test_excel_parser_contract.py.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def _write_contract_xml(test_xml_path: Path) -> None:
+ """Write a realistic XML document to the given path for contract testing.
+
+ The document mimics a technical specification with nested sections,
+ mixed content (text + child elements), and various container types.
+ """
+ xml_content = """\
+
+
+ API Specification v2.1
+
+ This document defines the public API contract for the Knowhere
+ document processing service, covering authentication, endpoints,
+ and error handling.
+
+
+ Authentication
+
+ All API requests require a valid API key passed in the
+ Authorization header using the Bearer token scheme.
+
+ -
+ API Key Format
+ Keys are 256-bit hex-encoded strings prefixed with "kh_".
+
+ -
+ Key Rotation
+ Keys may be rotated every 90 days. Old keys remain valid for a 24-hour grace period.
+
+
+
+ Endpoints
+
+ All endpoints are available under the base URL https://api.knowhereto.ai/v1.
+
+ -
+ POST /documents/upload
+ Upload a new document for processing. Accepts multipart form data with the file and optional metadata JSON.
+
+ -
+ GET /documents/{id}/status
+ Retrieve the current processing status of a document by its unique identifier.
+
+
+
+ Error Handling
+
+ The API uses standard HTTP status codes and returns error details in a consistent JSON envelope.
+
+
+"""
+ test_xml_path.write_text(xml_content, encoding="utf-8")
+
+
+def test_xml_parser_contract_produces_valid_parse_output(
+ worker_contract_environment: None,
+ tmp_path: Path,
+) -> None:
+ """Verify that a .xml file is routed through the XmlParseAdapter
+ and produces a ParseOutput with the expected DataFrame contract."""
+ from app.services.document_parser.parse_service import checkerboard_parse_output
+
+ xml_path = tmp_path / "api_spec.xml"
+ output_root = tmp_path / "parser-output"
+ _write_contract_xml(xml_path)
+
+ # Run through the public parser seam with LLM features disabled.
+ parse_output = checkerboard_parse_output(
+ file_full_path=str(xml_path),
+ filename="api_spec.xml",
+ output_dir=str(output_root),
+ internal_output_filename="api_spec.xml",
+ summary_image=False,
+ summary_table=False,
+ summary_txt=False,
+ smart_title_parse=False,
+ stopwords=[],
+ )
+
+ full_output_dir = parse_output.output_dir
+ parsed_df = parse_output.parsed_df
+
+ # ── Output directory contract ───────────────────────────────
+ assert full_output_dir.endswith("api_spec.xml"), (
+ f"Expected output directory to end with 'api_spec.xml', "
+ f"got: {full_output_dir}"
+ )
+ assert parsed_df is not None, (
+ "Expected parsed_df to be non-None for XML input"
+ )
+
+ # ── DataFrame column contract ───────────────────────────────
+ expected_columns = ["path", "content", "type", "summary", "keywords"]
+ for col in expected_columns:
+ assert col in parsed_df.columns, (
+ f"Expected column '{col}' in parsed_df, "
+ f"got columns: {list(parsed_df.columns)}"
+ )
+
+ # ── Content extraction ──────────────────────────────────────
+ all_content = " ".join(str(c) for c in parsed_df["content"].tolist() if c)
+ assert "API Specification" in all_content, (
+ "Expected document title in parsed output"
+ )
+ assert "Authentication" in all_content, (
+ "Expected section heading content in parsed output"
+ )
+ assert "Bearer token" in all_content, (
+ "Expected paragraph text in parsed output"
+ )
+ assert "kh_" in all_content, (
+ "Expected nested item text in parsed output"
+ )
+
+ # ── Path contract ───────────────────────────────────────────
+ paths = parsed_df["path"].tolist()
+ assert any(p.startswith("api_spec.xml") for p in paths), (
+ f"Expected at least one path starting with 'api_spec.xml', "
+ f"got paths: {paths}"
+ )
+
+
+def test_xml_parser_handles_malformed_xml_gracefully(
+ worker_contract_environment: None,
+ tmp_path: Path,
+) -> None:
+ """Verify that malformed XML is handled gracefully — the parser
+ should not crash but return a best-effort parse via fallback mode."""
+ from app.services.document_parser.parse_service import checkerboard_parse_output
+
+ # Write intentionally malformed XML (missing closing tag).
+ bad_xml_path = tmp_path / "bad.xml"
+ bad_xml_path.write_text(
+ "- Valid content
- Unclosed",
+ encoding="utf-8",
+ )
+ output_root = tmp_path / "parser-output"
+
+ # The parser must not raise an unhandled exception on malformed input.
+ parse_output = checkerboard_parse_output(
+ file_full_path=str(bad_xml_path),
+ filename="bad.xml",
+ output_dir=str(output_root),
+ internal_output_filename="bad.xml",
+ summary_image=False,
+ summary_table=False,
+ summary_txt=False,
+ smart_title_parse=False,
+ stopwords=[],
+ )
+
+ # Should still produce a valid ParseOutput (best-effort).
+ assert parse_output.parsed_df is not None, (
+ "Expected parsed_df to be non-None even for malformed XML"
+ )
+
+
+def test_xml_to_md_lines_extracts_text_hierarchy() -> None:
+ """Unit test: verify that _xml_to_md_lines correctly extracts text
+ from a structured XML document and preserves the hierarchy."""
+ from app.services.document_parser.formats.xml.parser import _xml_to_md_lines
+
+ xml = """\
+
+
+ Test Document
+
+ Overview
+ This is a test paragraph with important information.
+
+
+ Details
+
-
+ Item One
+ Description of the first item.
+
+ -
+ Item Two
+ Description of the second item.
+
+
+"""
+
+ lines = _xml_to_md_lines(xml)
+
+ all_text = "\n".join(lines)
+ assert "Test Document" in all_text, f"Expected 'Test Document' in {lines}"
+ assert "Overview" in all_text, f"Expected 'Overview' in {lines}"
+ assert "important information" in all_text, (
+ f"Expected paragraph text in {lines}"
+ )
+ assert "Item One" in all_text, f"Expected 'Item One' in {lines}"
+ assert "Item Two" in all_text, f"Expected 'Item Two' in {lines}"
+
+
+def test_xml_to_md_lines_handles_malformed() -> None:
+ """Unit test: malformed XML should fall back to raw text lines."""
+ from app.services.document_parser.formats.xml.parser import _xml_to_md_lines
+
+ lines = _xml_to_md_lines("- text
0, "Expected at least one line from fallback"
+
+
+def test_local_name_strips_namespace() -> None:
+ """Unit test: verify _local_name correctly strips XML namespace URIs."""
+ from app.services.document_parser.formats.xml.parser import _local_name
+
+ assert _local_name("{http://example.com/ns}section") == "section"
+ assert _local_name("paragraph") == "paragraph"
+ assert _local_name("{urn:oasis:names:tc:opendocument}body") == "body"