fix: The output of the parser in the ingestion pipeline contains HTML tags (#14920)

## Summary
This change fixes ingestion quality issues where MinerU parser output
may contain HTML fragments (for example, table-related tags like `<tr>`,
`<td>`, `<br>`), which were previously passed directly into
chunking/tokenization and degraded chunk quality.

The fix adds a sanitization step in the MinerU parser path so parsed
sections are normalized to clean text before chunking.

## Change Type (select all)
- [x] Bug fix
- [x] Ingestion pipeline improvement
- [x] Parser/chunking quality fix

## Related Issue
- https://github.com/infiniflow/ragflow/issues/14831
This commit is contained in:
Jonathan Chang
2026-05-24 22:06:36 -10:00
committed by GitHub
parent e6068a7f7e
commit 9d1006e4ec
5 changed files with 235 additions and 15 deletions

View File

@@ -18,6 +18,7 @@ import importlib.util
import inspect
import json
import sys
from contextlib import nullcontext
from functools import wraps
from pathlib import Path
from types import ModuleType, SimpleNamespace
@@ -140,6 +141,18 @@ def _set_request_json(monkeypatch, module, payload):
monkeypatch.setattr(module, "get_request_json", _request_json)
def _stub_url_safety(monkeypatch, module, unsafe_urls=None):
unsafe_urls = set(unsafe_urls or [])
def _assert_url_is_safe(url):
if url in unsafe_urls:
raise ValueError("blocked unsafe url")
return "safe.example", "93.184.216.34"
monkeypatch.setattr(module, "assert_url_is_safe", _assert_url_is_safe)
monkeypatch.setattr(module, "pin_dns_global", lambda *_args, **_kwargs: nullcontext())
@pytest.fixture(scope="session")
def auth():
return "unit-auth"
@@ -338,10 +351,20 @@ def test_create_validation_guards(monkeypatch):
res = _run(module.create.__wrapped__())
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"name": "srv", "url": 123, "server_type": "sse"})
res = _run(module.create.__wrapped__())
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"name": "srv", "url": "http://unsafe", "server_type": "sse"})
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
res = _run(module.create.__wrapped__())
assert "blocked unsafe url" in res["message"]
@pytest.mark.p2
def test_create_service_paths(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
base_payload = {
"name": "srv",
@@ -434,10 +457,20 @@ def test_update_validation_guards(monkeypatch):
res = _run(module.update("mcp-1"))
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"mcp_id": "mcp-1", "url": {"raw": "http://a"}})
res = _run(module.update("mcp-1"))
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"mcp_id": "mcp-1", "url": "http://unsafe"})
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
res = _run(module.update("mcp-1"))
assert "blocked unsafe url" in res["message"]
@pytest.mark.p2
def test_update_service_paths(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
existing = _DummyMCPServer(
id="mcp-1",
@@ -560,6 +593,7 @@ def test_rm_failure_success_and_exception(monkeypatch):
@pytest.mark.p2
def test_import_multiple_missing_servers_and_exception(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
_set_request_json(monkeypatch, module, {"mcpServers": {}})
res = _run(module.import_multiple.__wrapped__())
@@ -579,11 +613,15 @@ def test_import_multiple_missing_servers_and_exception(monkeypatch):
@pytest.mark.p2
def test_import_multiple_mixed_results(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
payload = {
"mcpServers": {
"missing_fields": {"type": "sse"},
"": {"type": "sse", "url": "http://empty"},
"invalid_type": {"type": "invalid", "url": "http://invalid"},
"non_string_url": {"type": "sse", "url": True},
"unsafe": {"type": "sse", "url": "http://unsafe"},
"dup": {"type": "sse", "url": "http://dup", "authorization_token": "dup-token"},
"tool_err": {"type": "sse", "url": "http://err"},
"insert_fail": {"type": "sse", "url": "http://fail"},
@@ -624,6 +662,12 @@ def test_import_multiple_mixed_results(monkeypatch):
assert "Missing required fields" in results["missing_fields"]["message"]
assert results[""]["success"] is False
assert "Invalid MCP name" in results[""]["message"]
assert results["invalid_type"]["success"] is False
assert "Unsupported MCP server type" in results["invalid_type"]["message"]
assert results["non_string_url"]["success"] is False
assert "Invalid url" in results["non_string_url"]["message"]
assert results["unsafe"]["success"] is False
assert "blocked unsafe url" in results["unsafe"]["message"]
assert results["tool_err"]["success"] is False
assert "tool call failed" in results["tool_err"]["message"]
assert results["insert_fail"]["success"] is False
@@ -693,11 +737,16 @@ def test_detail_download_success_and_exception(monkeypatch):
@pytest.mark.p2
def test_test_mcp_route_matrix_unit(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
_set_request_json(monkeypatch, module, {"url": "", "server_type": "sse"})
res = _run(module.test_mcp("mcp-1"))
assert "Invalid MCP url" in res["message"]
_set_request_json(monkeypatch, module, {"url": ["http://a"], "server_type": "sse"})
res = _run(module.test_mcp("mcp-1"))
assert "Invalid MCP url" in res["message"]
_set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "invalid"})
res = _run(module.test_mcp("mcp-1"))
assert "Unsupported MCP server type" in res["message"]

View File

@@ -18,6 +18,7 @@ import importlib.util
import inspect
import json
import sys
from contextlib import nullcontext
from functools import wraps
from pathlib import Path
from types import ModuleType, SimpleNamespace
@@ -140,6 +141,18 @@ def _set_request_json(monkeypatch, module, payload):
monkeypatch.setattr(module, "get_request_json", _request_json)
def _stub_url_safety(monkeypatch, module, unsafe_urls=None):
unsafe_urls = set(unsafe_urls or [])
def _assert_url_is_safe(url):
if url in unsafe_urls:
raise ValueError("blocked unsafe url")
return "safe.example", "93.184.216.34"
monkeypatch.setattr(module, "assert_url_is_safe", _assert_url_is_safe)
monkeypatch.setattr(module, "pin_dns_global", lambda *_args, **_kwargs: nullcontext())
@pytest.fixture(scope="session")
def auth():
return "unit-auth"
@@ -338,10 +351,20 @@ def test_create_validation_guards(monkeypatch):
res = _run(module.create.__wrapped__())
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"name": "srv", "url": 123, "server_type": "sse"})
res = _run(module.create.__wrapped__())
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"name": "srv", "url": "http://unsafe", "server_type": "sse"})
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
res = _run(module.create.__wrapped__())
assert "blocked unsafe url" in res["message"]
@pytest.mark.p2
def test_create_service_paths(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
base_payload = {
"name": "srv",
@@ -434,10 +457,20 @@ def test_update_validation_guards(monkeypatch):
res = _run(module.update("mcp-1"))
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"mcp_id": "mcp-1", "url": {"raw": "http://a"}})
res = _run(module.update("mcp-1"))
assert "Invalid url" in res["message"]
_set_request_json(monkeypatch, module, {"mcp_id": "mcp-1", "url": "http://unsafe"})
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
res = _run(module.update("mcp-1"))
assert "blocked unsafe url" in res["message"]
@pytest.mark.p2
def test_update_service_paths(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
existing = _DummyMCPServer(
id="mcp-1",
@@ -560,6 +593,7 @@ def test_rm_failure_success_and_exception(monkeypatch):
@pytest.mark.p2
def test_import_multiple_missing_servers_and_exception(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
_set_request_json(monkeypatch, module, {"mcpServers": {}})
res = _run(module.import_multiple.__wrapped__())
@@ -579,11 +613,15 @@ def test_import_multiple_missing_servers_and_exception(monkeypatch):
@pytest.mark.p2
def test_import_multiple_mixed_results(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module, {"http://unsafe"})
payload = {
"mcpServers": {
"missing_fields": {"type": "sse"},
"": {"type": "sse", "url": "http://empty"},
"invalid_type": {"type": "invalid", "url": "http://invalid"},
"non_string_url": {"type": "sse", "url": True},
"unsafe": {"type": "sse", "url": "http://unsafe"},
"dup": {"type": "sse", "url": "http://dup", "authorization_token": "dup-token"},
"tool_err": {"type": "sse", "url": "http://err"},
"insert_fail": {"type": "sse", "url": "http://fail"},
@@ -624,6 +662,12 @@ def test_import_multiple_mixed_results(monkeypatch):
assert "Missing required fields" in results["missing_fields"]["message"]
assert results[""]["success"] is False
assert "Invalid MCP name" in results[""]["message"]
assert results["invalid_type"]["success"] is False
assert "Unsupported MCP server type" in results["invalid_type"]["message"]
assert results["non_string_url"]["success"] is False
assert "Invalid url" in results["non_string_url"]["message"]
assert results["unsafe"]["success"] is False
assert "blocked unsafe url" in results["unsafe"]["message"]
assert results["tool_err"]["success"] is False
assert "tool call failed" in results["tool_err"]["message"]
assert results["insert_fail"]["success"] is False
@@ -693,11 +737,16 @@ def test_detail_download_success_and_exception(monkeypatch):
@pytest.mark.p2
def test_test_mcp_route_matrix_unit(monkeypatch):
module = _load_mcp_api(monkeypatch)
_stub_url_safety(monkeypatch, module)
_set_request_json(monkeypatch, module, {"url": "", "server_type": "sse"})
res = _run(module.test_mcp("mcp-1"))
assert "Invalid MCP url" in res["message"]
_set_request_json(monkeypatch, module, {"url": ["http://a"], "server_type": "sse"})
res = _run(module.test_mcp("mcp-1"))
assert "Invalid MCP url" in res["message"]
_set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "invalid"})
res = _run(module.test_mcp("mcp-1"))
assert "Unsupported MCP server type" in res["message"]

View File

@@ -0,0 +1,68 @@
import importlib.util
import logging
import sys
from pathlib import Path
from types import ModuleType
def _load_mineru_parser(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
deepdoc_mod = ModuleType("deepdoc")
deepdoc_mod.__path__ = [str(repo_root / "deepdoc")]
monkeypatch.setitem(sys.modules, "deepdoc", deepdoc_mod)
parser_mod = ModuleType("deepdoc.parser")
parser_mod.__path__ = [str(repo_root / "deepdoc" / "parser")]
monkeypatch.setitem(sys.modules, "deepdoc.parser", parser_mod)
pdf_parser_mod = ModuleType("deepdoc.parser.pdf_parser")
class _RAGFlowPdfParser:
pass
pdf_parser_mod.RAGFlowPdfParser = _RAGFlowPdfParser
monkeypatch.setitem(sys.modules, "deepdoc.parser.pdf_parser", pdf_parser_mod)
utils_mod = ModuleType("deepdoc.parser.utils")
utils_mod.extract_pdf_outlines = lambda *_args, **_kwargs: []
monkeypatch.setitem(sys.modules, "deepdoc.parser.utils", utils_mod)
module_name = "test_mineru_parser_unit_module"
module_path = repo_root / "deepdoc" / "parser" / "mineru_parser.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def test_sanitize_section_text_removes_escaped_html_tags(monkeypatch):
module = _load_mineru_parser(monkeypatch)
text = "&lt;table&gt;&lt;tr&gt;&lt;td&gt;Alpha&lt;/td&gt;&lt;td&gt;Beta&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;"
sanitized = module.MinerUParser._sanitize_section_text(text)
assert sanitized == "AlphaBeta"
assert "<td>" not in sanitized
assert "</td>" not in sanitized
def test_transfer_to_sections_logs_sections_dropped_after_sanitization(monkeypatch, caplog):
module = _load_mineru_parser(monkeypatch)
parser = module.MinerUParser()
outputs = [
{
"type": module.MinerUContentType.TEXT,
"text": "&lt;td&gt;&lt;/td&gt;",
"page_idx": 0,
"bbox": (0, 0, 1, 1),
}
]
with caplog.at_level(logging.DEBUG, logger=parser.logger.name):
sections = parser._transfer_to_sections(outputs, parse_method="pipeline")
assert sections == []
assert "Skip section after sanitization" in caplog.text
assert f"type={module.MinerUContentType.TEXT}" in caplog.text