feat(data-process): 清理PDF文档级噪声
This commit is contained in:
@@ -37,6 +37,7 @@ from app.modules.data_process.algorithms import (
|
|||||||
desensitize_pii,
|
desensitize_pii,
|
||||||
desensitize_structured_record,
|
desensitize_structured_record,
|
||||||
detect_document_structure,
|
detect_document_structure,
|
||||||
|
detect_pdf_document_noise,
|
||||||
estimate_token_count,
|
estimate_token_count,
|
||||||
extract_pdf_page_texts,
|
extract_pdf_page_texts,
|
||||||
generate_standard_records,
|
generate_standard_records,
|
||||||
@@ -44,6 +45,7 @@ from app.modules.data_process.algorithms import (
|
|||||||
near_duplicate_fingerprint,
|
near_duplicate_fingerprint,
|
||||||
parse_text_content,
|
parse_text_content,
|
||||||
preprocess_structured_records,
|
preprocess_structured_records,
|
||||||
|
remove_document_noise,
|
||||||
score_quality,
|
score_quality,
|
||||||
)
|
)
|
||||||
from app.modules.data_process.generation import generate_model_records
|
from app.modules.data_process.generation import generate_model_records
|
||||||
@@ -453,13 +455,25 @@ def _build_preview_items(
|
|||||||
for source in source_files:
|
for source in source_files:
|
||||||
parsed = _parse_stored_source(source)
|
parsed = _parse_stored_source(source)
|
||||||
if process_type == "unstructured":
|
if process_type == "unstructured":
|
||||||
|
document_noise_spans = tuple(source.get("document_noise_spans") or ())
|
||||||
chunks = _chunk_source_text(parsed.text, config, preprocess_options)
|
chunks = _chunk_source_text(parsed.text, config, preprocess_options)
|
||||||
for chunk, heading_path in chunks:
|
for chunk, heading_path in chunks:
|
||||||
|
content = (
|
||||||
|
remove_document_noise(
|
||||||
|
chunk.content,
|
||||||
|
document_noise_spans,
|
||||||
|
source_offset=chunk.start,
|
||||||
|
)
|
||||||
|
if should_clean_invalid and document_noise_spans
|
||||||
|
else chunk.content
|
||||||
|
)
|
||||||
preprocess_flags = content_quality_flags(
|
preprocess_flags = content_quality_flags(
|
||||||
chunk.content,
|
content,
|
||||||
min_chars=0,
|
min_chars=0,
|
||||||
min_tokens=0,
|
min_tokens=0,
|
||||||
)
|
)
|
||||||
|
if content != chunk.content:
|
||||||
|
preprocess_flags = (*preprocess_flags, "document_noise_removed")
|
||||||
flag_set = set(preprocess_flags)
|
flag_set = set(preprocess_flags)
|
||||||
if "clean_invalid_content" in preprocess_options and flag_set & {
|
if "clean_invalid_content" in preprocess_options and flag_set & {
|
||||||
"empty_content",
|
"empty_content",
|
||||||
@@ -475,20 +489,19 @@ def _build_preview_items(
|
|||||||
}:
|
}:
|
||||||
continue
|
continue
|
||||||
if "deduplicate_content" in preprocess_options:
|
if "deduplicate_content" in preprocess_options:
|
||||||
band_keys = _near_duplicate_band_keys(chunk.content)
|
band_keys = _near_duplicate_band_keys(content)
|
||||||
candidates = {
|
candidates = {
|
||||||
previous
|
previous
|
||||||
for key in band_keys
|
for key in band_keys
|
||||||
for previous in seen_near_duplicate_bands.get(key, ())
|
for previous in seen_near_duplicate_bands.get(key, ())
|
||||||
}
|
}
|
||||||
if any(
|
if any(
|
||||||
_safe_near_duplicate(chunk.content, previous)
|
_safe_near_duplicate(content, previous)
|
||||||
for previous in candidates
|
for previous in candidates
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
for key in band_keys:
|
for key in band_keys:
|
||||||
seen_near_duplicate_bands.setdefault(key, []).append(chunk.content)
|
seen_near_duplicate_bands.setdefault(key, []).append(content)
|
||||||
content = chunk.content
|
|
||||||
pii_counts: dict[str, int] = {}
|
pii_counts: dict[str, int] = {}
|
||||||
if should_desensitize:
|
if should_desensitize:
|
||||||
content, pii_counts = desensitize_pii(content)
|
content, pii_counts = desensitize_pii(content)
|
||||||
@@ -512,7 +525,7 @@ def _build_preview_items(
|
|||||||
"source_end": chunk.end,
|
"source_end": chunk.end,
|
||||||
"source_start_line": chunk.start_line,
|
"source_start_line": chunk.start_line,
|
||||||
"source_end_line": chunk.end_line,
|
"source_end_line": chunk.end_line,
|
||||||
"token_count": chunk.token_count,
|
"token_count": estimate_token_count(content),
|
||||||
"status": "modified" if content != chunk.content else "original",
|
"status": "modified" if content != chunk.content else "original",
|
||||||
"quality_score": quality,
|
"quality_score": quality,
|
||||||
}
|
}
|
||||||
@@ -1234,6 +1247,7 @@ def pull_external_source(
|
|||||||
def _prepare_preview_items(
|
def _prepare_preview_items(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
store: DataProcessStore,
|
store: DataProcessStore,
|
||||||
|
storage: LocalDataProcessStorage,
|
||||||
source_file_ids: list[str] | None = None,
|
source_file_ids: list[str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
task = store.get_task(task_id)
|
task = store.get_task(task_id)
|
||||||
@@ -1251,6 +1265,48 @@ def _prepare_preview_items(
|
|||||||
]
|
]
|
||||||
if not sources:
|
if not sources:
|
||||||
raise InvalidStateError("at least one source file is required")
|
raise InvalidStateError("at least one source file is required")
|
||||||
|
preprocess_options = _preprocess_options(task.get("config") or {})
|
||||||
|
if (
|
||||||
|
task.get("process_type") == "unstructured"
|
||||||
|
and preprocess_options & {"clean_invalid", "clean_invalid_content"}
|
||||||
|
):
|
||||||
|
for index, source in enumerate(sources):
|
||||||
|
if str(source.get("file_format") or "").lower() != "pdf":
|
||||||
|
continue
|
||||||
|
storage_object_id = str(source.get("storage_object_id") or "")
|
||||||
|
actual_size = storage.file_size(
|
||||||
|
storage_object_id,
|
||||||
|
expected_task_id=task_id,
|
||||||
|
expected_source_file_id=str(source["id"]),
|
||||||
|
)
|
||||||
|
if actual_size is None:
|
||||||
|
logger.info(
|
||||||
|
"skip PDF document noise detection for unavailable legacy source %s",
|
||||||
|
source["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
expected_size = int(source.get("size_bytes") or 0)
|
||||||
|
if expected_size and actual_size != expected_size:
|
||||||
|
raise ValueError("source object size does not match metadata")
|
||||||
|
raw = b"".join(
|
||||||
|
storage.iter_bytes(
|
||||||
|
storage_object_id,
|
||||||
|
expected_task_id=task_id,
|
||||||
|
expected_source_file_id=str(source["id"]),
|
||||||
|
expected_size=actual_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pages = extract_pdf_page_texts(raw)
|
||||||
|
extracted_text = "\n\n".join(page.text for page in pages if page.text)
|
||||||
|
if extracted_text != str(source.get("content") or ""):
|
||||||
|
logger.warning(
|
||||||
|
"skip PDF document noise detection because stored offsets differ for %s",
|
||||||
|
source["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
enriched = dict(source)
|
||||||
|
enriched["document_noise_spans"] = detect_pdf_document_noise(pages)
|
||||||
|
sources[index] = enriched
|
||||||
items = _build_preview_items(task, sources)
|
items = _build_preview_items(task, sources)
|
||||||
if not items and source_file_ids is None:
|
if not items and source_file_ids is None:
|
||||||
raise InvalidStateError("source files did not produce preview items")
|
raise InvalidStateError("source files did not produce preview items")
|
||||||
@@ -1262,10 +1318,11 @@ def build_preview(
|
|||||||
task_id: str,
|
task_id: str,
|
||||||
payload: PreviewBuildRequest = Body(default_factory=PreviewBuildRequest),
|
payload: PreviewBuildRequest = Body(default_factory=PreviewBuildRequest),
|
||||||
store: DataProcessStore = Depends(get_data_process_store),
|
store: DataProcessStore = Depends(get_data_process_store),
|
||||||
|
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with api_errors():
|
with api_errors():
|
||||||
selected_ids = payload.source_file_ids
|
selected_ids = payload.source_file_ids
|
||||||
items = _prepare_preview_items(task_id, store, selected_ids)
|
items = _prepare_preview_items(task_id, store, storage, selected_ids)
|
||||||
created = store.replace_preview_items(
|
created = store.replace_preview_items(
|
||||||
task_id,
|
task_id,
|
||||||
items,
|
items,
|
||||||
@@ -1405,9 +1462,10 @@ def start(
|
|||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
payload: GenerateRequest = Body(default_factory=GenerateRequest),
|
payload: GenerateRequest = Body(default_factory=GenerateRequest),
|
||||||
store: DataProcessStore = Depends(get_data_process_store),
|
store: DataProcessStore = Depends(get_data_process_store),
|
||||||
|
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with api_errors():
|
with api_errors():
|
||||||
items = _prepare_preview_items(task_id, store)
|
items = _prepare_preview_items(task_id, store, storage)
|
||||||
store.replace_preview_items(task_id, items)
|
store.replace_preview_items(task_id, items)
|
||||||
return _start_generation(task_id, payload, background_tasks, store)
|
return _start_generation(task_id, payload, background_tasks, store)
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ class PdfPageText:
|
|||||||
source_end: int
|
source_end: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentNoiseSpan:
|
||||||
|
"""PDF 中可安全从展示内容移除的文本范围。"""
|
||||||
|
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
kind: Literal["page_number", "repeated_margin", "table_of_contents"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TextChunk:
|
class TextChunk:
|
||||||
"""带有可追溯来源位置的非结构化文本切片。"""
|
"""带有可追溯来源位置的非结构化文本切片。"""
|
||||||
@@ -496,6 +505,240 @@ def extract_pdf_page_texts(raw: bytes) -> tuple[PdfPageText, ...]:
|
|||||||
return tuple(pages)
|
return tuple(pages)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _PdfLine:
|
||||||
|
text: str
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
|
||||||
|
|
||||||
|
_PDF_PAGE_NUMBER_LINE_PATTERN = re.compile(
|
||||||
|
r"^(?:页次\s*)?(?:第\s*)?(?P<page>\d+)\s*页\s*"
|
||||||
|
r"(?:(?:[//]\s*)?共\s*(?P<total>\d+)\s*页)?$"
|
||||||
|
)
|
||||||
|
_PDF_FRACTION_PAGE_LINE_PATTERN = re.compile(
|
||||||
|
r"^[—–-]?\s*(?P<page>\d+)\s*[//]\s*(?P<total>\d+)\s*[—–-]?$"
|
||||||
|
)
|
||||||
|
_PDF_CLASSIFICATION_LABEL_PATTERN = re.compile(
|
||||||
|
r"^(?:(?:秘密等级|密级)\s*)?(?:商密|秘密|机密|绝密)"
|
||||||
|
r"\s*(?:[【\[((][^】\]))]{1,8}[】\]))])?$"
|
||||||
|
)
|
||||||
|
_TOC_TITLE_PATTERN = re.compile(r"^(?:目\s*录|contents)$", re.IGNORECASE)
|
||||||
|
_TOC_LEADER_ENTRY_PATTERN = re.compile(
|
||||||
|
r"(?:[..…·•]\s*){3,}\s*\d{1,4}\s*$"
|
||||||
|
)
|
||||||
|
_TOC_NUMBERED_ENTRY_PATTERN = re.compile(
|
||||||
|
r"^(?:第[\u3400-\u4dbf\u4e00-\u9fff]{1,12}章|附表\s*\d+|\d+(?:\.\d+)+)"
|
||||||
|
r"\s+.+\s+\d{1,4}\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MARGIN_TEMPLATE_KEYWORDS = (
|
||||||
|
"页",
|
||||||
|
"页次",
|
||||||
|
"版本",
|
||||||
|
"文件编码",
|
||||||
|
"秘密等级",
|
||||||
|
"密级",
|
||||||
|
"商密",
|
||||||
|
"confidential",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_page_lines(page: PdfPageText) -> tuple[_PdfLine, ...]:
|
||||||
|
lines: list[_PdfLine] = []
|
||||||
|
local_offset = 0
|
||||||
|
for raw_line in page.text.splitlines(keepends=True):
|
||||||
|
content = raw_line.rstrip("\r\n")
|
||||||
|
leading = len(content) - len(content.lstrip())
|
||||||
|
trailing = len(content.rstrip())
|
||||||
|
text = content.strip()
|
||||||
|
if text:
|
||||||
|
lines.append(
|
||||||
|
_PdfLine(
|
||||||
|
text=text,
|
||||||
|
start=page.source_start + local_offset + leading,
|
||||||
|
end=page.source_start + local_offset + trailing,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local_offset += len(raw_line)
|
||||||
|
return tuple(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_standalone_page_number(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
physical_page: int,
|
||||||
|
page_count: int,
|
||||||
|
) -> bool:
|
||||||
|
normalized = unicodedata.normalize("NFKC", text).strip()
|
||||||
|
match = _PDF_PAGE_NUMBER_LINE_PATTERN.fullmatch(normalized)
|
||||||
|
if match is None:
|
||||||
|
match = _PDF_FRACTION_PAGE_LINE_PATTERN.fullmatch(normalized)
|
||||||
|
if match is None or int(match.group("page")) != physical_page:
|
||||||
|
return False
|
||||||
|
total = match.groupdict().get("total")
|
||||||
|
return total is None or int(total) == page_count
|
||||||
|
|
||||||
|
|
||||||
|
def _margin_signature(text: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKC", text).casefold()
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
if any(keyword in normalized for keyword in _MARGIN_TEMPLATE_KEYWORDS):
|
||||||
|
normalized = re.sub(r"\d+", "#", normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _has_margin_metadata_keyword(text: str) -> bool:
|
||||||
|
normalized = unicodedata.normalize("NFKC", text).casefold()
|
||||||
|
return any(keyword in normalized for keyword in _MARGIN_TEMPLATE_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_meaningful_margin_signature(signature: str) -> bool:
|
||||||
|
return len(re.sub(r"[#\W_]+", "", signature, flags=re.UNICODE)) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def _is_toc_leader_entry(text: str) -> bool:
|
||||||
|
return bool(_TOC_LEADER_ENTRY_PATTERN.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_toc_numbered_entry(text: str) -> bool:
|
||||||
|
return bool(_TOC_NUMBERED_ENTRY_PATTERN.fullmatch(text))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_pdf_document_noise(
|
||||||
|
pages: Sequence[PdfPageText],
|
||||||
|
) -> tuple[DocumentNoiseSpan, ...]:
|
||||||
|
"""识别 PDF 中的独立页码、重复页边内容和高置信目录。
|
||||||
|
|
||||||
|
规则只查看每页顶部 5 行和底部 3 行来推断页眉页脚;目录必须有
|
||||||
|
明显的点引导线密度,避免仅因正文中出现“目录”或章节标题而误删。
|
||||||
|
"""
|
||||||
|
|
||||||
|
page_lines = tuple(_pdf_page_lines(page) for page in pages)
|
||||||
|
detected: dict[tuple[int, int], DocumentNoiseSpan] = {}
|
||||||
|
|
||||||
|
def mark(
|
||||||
|
line: _PdfLine,
|
||||||
|
kind: Literal["page_number", "repeated_margin", "table_of_contents"],
|
||||||
|
) -> None:
|
||||||
|
detected.setdefault(
|
||||||
|
(line.start, line.end),
|
||||||
|
DocumentNoiseSpan(
|
||||||
|
start=line.start,
|
||||||
|
end=line.end,
|
||||||
|
kind=kind,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for page, lines in zip(pages, page_lines, strict=True):
|
||||||
|
for line in lines:
|
||||||
|
if _is_standalone_page_number(
|
||||||
|
line.text,
|
||||||
|
physical_page=page.page_number,
|
||||||
|
page_count=len(pages),
|
||||||
|
):
|
||||||
|
mark(line, "page_number")
|
||||||
|
outer_margin_lines = (*lines[:2], *lines[-2:])
|
||||||
|
for line in outer_margin_lines:
|
||||||
|
if _PDF_CLASSIFICATION_LABEL_PATTERN.fullmatch(line.text):
|
||||||
|
mark(line, "repeated_margin")
|
||||||
|
|
||||||
|
# 只在三页及以上文档中推断通用页眉页脚,避免短文档误删。
|
||||||
|
if len(pages) >= 3:
|
||||||
|
signature_pages: dict[str, set[int]] = {}
|
||||||
|
candidate_lines: list[tuple[int, _PdfLine, str]] = []
|
||||||
|
for page_index, lines in enumerate(page_lines):
|
||||||
|
boundary_lines = (
|
||||||
|
*((line, index < 2) for index, line in enumerate(lines[:5])),
|
||||||
|
*((line, index < 2) for index, line in enumerate(reversed(lines[-3:]))),
|
||||||
|
)
|
||||||
|
seen_ranges: set[tuple[int, int]] = set()
|
||||||
|
for line, is_outer_margin in boundary_lines:
|
||||||
|
line_range = (line.start, line.end)
|
||||||
|
if (
|
||||||
|
line_range in seen_ranges
|
||||||
|
or line_range in detected
|
||||||
|
or len(line.text) > 160
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
seen_ranges.add(line_range)
|
||||||
|
if not is_outer_margin and not _has_margin_metadata_keyword(line.text):
|
||||||
|
continue
|
||||||
|
signature = _margin_signature(line.text)
|
||||||
|
if not _has_meaningful_margin_signature(signature):
|
||||||
|
continue
|
||||||
|
signature_pages.setdefault(signature, set()).add(page_index)
|
||||||
|
candidate_lines.append((page_index, line, signature))
|
||||||
|
minimum_pages = max(3, math.ceil(len(pages) * 0.3))
|
||||||
|
repeated_signatures = {
|
||||||
|
signature
|
||||||
|
for signature, matching_pages in signature_pages.items()
|
||||||
|
if len(matching_pages) >= minimum_pages
|
||||||
|
}
|
||||||
|
for _, line, signature in candidate_lines:
|
||||||
|
if signature in repeated_signatures:
|
||||||
|
mark(line, "repeated_margin")
|
||||||
|
|
||||||
|
# 先依据强证据判定目录页,再补充删除少量不带点引导线的编号目录项。
|
||||||
|
toc_active = False
|
||||||
|
for lines in page_lines:
|
||||||
|
content_lines = [
|
||||||
|
line for line in lines if (line.start, line.end) not in detected
|
||||||
|
]
|
||||||
|
leader_entries = [line for line in content_lines if _is_toc_leader_entry(line.text)]
|
||||||
|
titles = [line for line in content_lines if _TOC_TITLE_PATTERN.fullmatch(line.text)]
|
||||||
|
starts_toc = bool(titles and len(leader_entries) >= 2)
|
||||||
|
is_toc_dense = bool(
|
||||||
|
len(leader_entries) >= 3
|
||||||
|
and len(leader_entries) / max(1, len(content_lines)) >= 0.5
|
||||||
|
)
|
||||||
|
if not (starts_toc or (toc_active and is_toc_dense)):
|
||||||
|
toc_active = False
|
||||||
|
continue
|
||||||
|
toc_active = True
|
||||||
|
for line in content_lines:
|
||||||
|
if (
|
||||||
|
line in titles
|
||||||
|
or _is_toc_leader_entry(line.text)
|
||||||
|
or _is_toc_numbered_entry(line.text)
|
||||||
|
):
|
||||||
|
mark(line, "table_of_contents")
|
||||||
|
|
||||||
|
return tuple(sorted(detected.values(), key=lambda span: (span.start, span.end)))
|
||||||
|
|
||||||
|
|
||||||
|
def remove_document_noise(
|
||||||
|
text: str,
|
||||||
|
spans: Sequence[DocumentNoiseSpan],
|
||||||
|
*,
|
||||||
|
source_offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""按原文绝对偏移移除噪声,不改动调用方保留的原文及偏移。"""
|
||||||
|
|
||||||
|
text_end = source_offset + len(text)
|
||||||
|
intersections = sorted(
|
||||||
|
(
|
||||||
|
max(0, span.start - source_offset),
|
||||||
|
min(len(text), span.end - source_offset),
|
||||||
|
)
|
||||||
|
for span in spans
|
||||||
|
if span.start < text_end and span.end > source_offset
|
||||||
|
)
|
||||||
|
if not intersections:
|
||||||
|
return text
|
||||||
|
parts: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for start, end in intersections:
|
||||||
|
if end <= cursor:
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
parts.append(text[cursor:start])
|
||||||
|
cursor = end
|
||||||
|
parts.append(text[cursor:])
|
||||||
|
cleaned = normalize_text("".join(parts))
|
||||||
|
return re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||||
|
|
||||||
|
|
||||||
def _extract_pdf_text(raw: bytes) -> str:
|
def _extract_pdf_text(raw: bytes) -> str:
|
||||||
return "\n\n".join(page.text for page in extract_pdf_page_texts(raw) if page.text)
|
return "\n\n".join(page.text for page in extract_pdf_page_texts(raw) if page.text)
|
||||||
|
|
||||||
@@ -2737,6 +2980,7 @@ __all__ = [
|
|||||||
"ChunkMethod",
|
"ChunkMethod",
|
||||||
"DatasetSplit",
|
"DatasetSplit",
|
||||||
"DocumentHeading",
|
"DocumentHeading",
|
||||||
|
"DocumentNoiseSpan",
|
||||||
"DocumentStructure",
|
"DocumentStructure",
|
||||||
"ParsedText",
|
"ParsedText",
|
||||||
"PdfPageText",
|
"PdfPageText",
|
||||||
@@ -2752,6 +2996,7 @@ __all__ = [
|
|||||||
"desensitize_pii",
|
"desensitize_pii",
|
||||||
"desensitize_structured_record",
|
"desensitize_structured_record",
|
||||||
"detect_document_structure",
|
"detect_document_structure",
|
||||||
|
"detect_pdf_document_noise",
|
||||||
"detect_text_format",
|
"detect_text_format",
|
||||||
"deduplicate_structured_records",
|
"deduplicate_structured_records",
|
||||||
"estimate_token_count",
|
"estimate_token_count",
|
||||||
@@ -2773,6 +3018,7 @@ __all__ = [
|
|||||||
"preprocess_structured_records",
|
"preprocess_structured_records",
|
||||||
"protected_context_ranges",
|
"protected_context_ranges",
|
||||||
"record_fingerprint",
|
"record_fingerprint",
|
||||||
|
"remove_document_noise",
|
||||||
"score_quality",
|
"score_quality",
|
||||||
"stable_split",
|
"stable_split",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ from pptx.util import Inches
|
|||||||
from pypdf import PdfWriter
|
from pypdf import PdfWriter
|
||||||
|
|
||||||
from app.modules.data_process.algorithms import (
|
from app.modules.data_process.algorithms import (
|
||||||
|
PdfPageText,
|
||||||
chunk_unstructured,
|
chunk_unstructured,
|
||||||
content_quality_flags,
|
content_quality_flags,
|
||||||
desensitize_pii,
|
desensitize_pii,
|
||||||
desensitize_structured_record,
|
desensitize_structured_record,
|
||||||
detect_document_structure,
|
detect_document_structure,
|
||||||
|
detect_pdf_document_noise,
|
||||||
detect_text_format,
|
detect_text_format,
|
||||||
estimate_token_count,
|
estimate_token_count,
|
||||||
extract_pdf_page_texts,
|
extract_pdf_page_texts,
|
||||||
@@ -30,11 +32,32 @@ from app.modules.data_process.algorithms import (
|
|||||||
parse_text_content,
|
parse_text_content,
|
||||||
preprocess_structured_records,
|
preprocess_structured_records,
|
||||||
record_fingerprint,
|
record_fingerprint,
|
||||||
|
remove_document_noise,
|
||||||
score_quality,
|
score_quality,
|
||||||
stable_split,
|
stable_split,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_page_texts(*texts: str) -> tuple[PdfPageText, ...]:
|
||||||
|
pages: list[PdfPageText] = []
|
||||||
|
offset = 0
|
||||||
|
for page_number, text in enumerate(texts, start=1):
|
||||||
|
normalized = normalize_text(text)
|
||||||
|
if pages:
|
||||||
|
offset += 2
|
||||||
|
start = offset
|
||||||
|
offset += len(normalized)
|
||||||
|
pages.append(
|
||||||
|
PdfPageText(
|
||||||
|
page_number=page_number,
|
||||||
|
text=normalized,
|
||||||
|
source_start=start,
|
||||||
|
source_end=offset,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(pages)
|
||||||
|
|
||||||
|
|
||||||
def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
||||||
stream = f"BT /F1 12 Tf 72 720 Td ({text}) Tj ET".encode("ascii")
|
stream = f"BT /F1 12 Tf 72 720 Td ({text}) Tj ET".encode("ascii")
|
||||||
objects = [
|
objects = [
|
||||||
@@ -206,6 +229,91 @@ def test_parse_pdf_docx_xlsx_and_pptx() -> None:
|
|||||||
assert parsed_pptx.records == ()
|
assert parsed_pptx.records == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_document_noise_removes_headers_page_numbers_and_toc_safely() -> None:
|
||||||
|
pages = _pdf_page_texts(
|
||||||
|
"""
|
||||||
|
远光制度文件 文件编码 2024
|
||||||
|
秘密等级 商密【中】
|
||||||
|
第 1 页 共 5 页
|
||||||
|
正文第一页,关于适用范围的说明。
|
||||||
|
业务提示保留
|
||||||
|
第一页补充说明甲
|
||||||
|
第一页补充说明乙
|
||||||
|
第一页补充说明丙
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
远光制度文件 文件编码 2024
|
||||||
|
秘密等级 商密【中】
|
||||||
|
第 2 页 共 5 页
|
||||||
|
目 录
|
||||||
|
第一章 总则........3
|
||||||
|
第二章 报销申请........4
|
||||||
|
第三章 附则........5
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
远光制度文件 文件编码 2024
|
||||||
|
秘密等级 商密【中】
|
||||||
|
第 3 页 共 5 页
|
||||||
|
1.1 管理要求........6
|
||||||
|
1.2 审批职责 7
|
||||||
|
1.3 费用标准........8
|
||||||
|
1.4 例外处理........9
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
远光制度文件 文件编码 2024
|
||||||
|
秘密等级 商密【中】
|
||||||
|
第 4 页 共 5 页
|
||||||
|
正文中可以说“请参见第 3 页说明”,不应误删。
|
||||||
|
第 99 页 共 100 页
|
||||||
|
系统可用率........99.9%
|
||||||
|
业务提示保留
|
||||||
|
第四页补充说明甲
|
||||||
|
第四页补充说明乙
|
||||||
|
第四页补充说明丙
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
远光制度文件 文件编码 2024
|
||||||
|
秘密等级 商密【中】
|
||||||
|
第 5 页 共 5 页
|
||||||
|
本办法自发布之日起施行。
|
||||||
|
业务提示保留
|
||||||
|
第五页补充说明甲
|
||||||
|
第五页补充说明乙
|
||||||
|
第五页补充说明丙
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
source = "\n\n".join(page.text for page in pages)
|
||||||
|
|
||||||
|
spans = detect_pdf_document_noise(pages)
|
||||||
|
cleaned = remove_document_noise(source, spans)
|
||||||
|
|
||||||
|
assert {span.kind for span in spans} == {
|
||||||
|
"page_number",
|
||||||
|
"repeated_margin",
|
||||||
|
"table_of_contents",
|
||||||
|
}
|
||||||
|
assert "远光制度文件" not in cleaned
|
||||||
|
assert "商密【中】" not in cleaned
|
||||||
|
assert "第 1 页 共 5 页" not in cleaned
|
||||||
|
assert "第一章 总则" not in cleaned
|
||||||
|
assert "1.2 审批职责 7" not in cleaned
|
||||||
|
assert "请参见第 3 页说明" in cleaned
|
||||||
|
assert "第 99 页 共 100 页" in cleaned
|
||||||
|
assert "系统可用率........99.9%" in cleaned
|
||||||
|
assert cleaned.count("业务提示保留") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_document_noise_does_not_infer_repeated_margins_for_short_documents() -> None:
|
||||||
|
pages = _pdf_page_texts(
|
||||||
|
"公司内部文件\n正文 A",
|
||||||
|
"公司内部文件\n正文 B",
|
||||||
|
)
|
||||||
|
|
||||||
|
spans = detect_pdf_document_noise(pages)
|
||||||
|
|
||||||
|
assert not any(span.kind == "repeated_margin" for span in spans)
|
||||||
|
|
||||||
|
|
||||||
def test_xlsx_merged_multilevel_headers_are_flattened_without_losing_columns() -> None:
|
def test_xlsx_merged_multilevel_headers_are_flattened_without_losing_columns() -> None:
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
worksheet = workbook.active
|
worksheet = workbook.active
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from openpyxl import Workbook
|
|||||||
|
|
||||||
from app.api.v1.endpoints import data_process as data_process_endpoint
|
from app.api.v1.endpoints import data_process as data_process_endpoint
|
||||||
from app.api.v1.endpoints.data_process import router
|
from app.api.v1.endpoints.data_process import router
|
||||||
from app.modules.data_process.algorithms import normalize_text
|
from app.modules.data_process.algorithms import DocumentNoiseSpan, normalize_text
|
||||||
from app.modules.data_process.storage import (
|
from app.modules.data_process.storage import (
|
||||||
DataProcessStorageError,
|
DataProcessStorageError,
|
||||||
LocalDataProcessStorage,
|
LocalDataProcessStorage,
|
||||||
@@ -443,20 +443,49 @@ def _stored_files(storage: LocalDataProcessStorage) -> list[Path]:
|
|||||||
return [path for path in storage.root.rglob("*") if path.is_file() or path.is_symlink()]
|
return [path for path in storage.root.rglob("*") if path.is_file() or path.is_symlink()]
|
||||||
|
|
||||||
|
|
||||||
def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
def _minimal_pdf_pages(*page_texts: str) -> bytes:
|
||||||
stream = f"BT /F1 12 Tf 72 720 Td ({text}) Tj ET".encode("ascii")
|
if not page_texts:
|
||||||
|
raise ValueError("at least one PDF page is required")
|
||||||
|
font_object_number = 3 + len(page_texts) * 2
|
||||||
|
page_object_numbers = [3 + index * 2 for index in range(len(page_texts))]
|
||||||
objects = [
|
objects = [
|
||||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
||||||
(
|
(
|
||||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
b"<< /Type /Pages /Kids ["
|
||||||
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
|
+ b" ".join(f"{number} 0 R".encode() for number in page_object_numbers)
|
||||||
|
+ b"] /Count "
|
||||||
|
+ str(len(page_texts)).encode()
|
||||||
|
+ b" >>"
|
||||||
),
|
),
|
||||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
|
|
||||||
+ stream
|
|
||||||
+ b"\nendstream",
|
|
||||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
||||||
]
|
]
|
||||||
|
for index, text in enumerate(page_texts):
|
||||||
|
content_object_number = page_object_numbers[index] + 1
|
||||||
|
commands = [b"BT /F1 12 Tf 72 720 Td"]
|
||||||
|
for line_index, line in enumerate(text.splitlines()):
|
||||||
|
escaped = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||||
|
if line_index:
|
||||||
|
commands.append(b"0 -16 Td")
|
||||||
|
commands.append(f"({escaped}) Tj".encode("ascii"))
|
||||||
|
commands.append(b"ET")
|
||||||
|
stream = b" ".join(commands)
|
||||||
|
objects.extend(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||||
|
b"/Resources << /Font << /F1 "
|
||||||
|
+ str(font_object_number).encode()
|
||||||
|
+ b" 0 R >> >> /Contents "
|
||||||
|
+ str(content_object_number).encode()
|
||||||
|
+ b" 0 R >>"
|
||||||
|
),
|
||||||
|
b"<< /Length "
|
||||||
|
+ str(len(stream)).encode()
|
||||||
|
+ b" >>\nstream\n"
|
||||||
|
+ stream
|
||||||
|
+ b"\nendstream",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||||
result = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
result = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||||
offsets = [0]
|
offsets = [0]
|
||||||
for object_number, value in enumerate(objects, start=1):
|
for object_number, value in enumerate(objects, start=1):
|
||||||
@@ -478,6 +507,10 @@ def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
|||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
||||||
|
return _minimal_pdf_pages(text)
|
||||||
|
|
||||||
|
|
||||||
def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
||||||
client, store, _ = make_client(tmp_path)
|
client, store, _ = make_client(tmp_path)
|
||||||
created = client.post(
|
created = client.post(
|
||||||
@@ -1127,6 +1160,59 @@ def test_pdf_raw_preview_streams_original_file_and_supports_ranges(tmp_path: Pat
|
|||||||
assert legacy_pages.status_code == 410
|
assert legacy_pages.status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_preview_build_cleans_stored_document_noise_without_offset_drift(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
client, _, _ = make_client(tmp_path)
|
||||||
|
task_id = client.post(
|
||||||
|
"/modelTF/data-process",
|
||||||
|
json={
|
||||||
|
"name": "PDF 文档噪声清理",
|
||||||
|
"process_type": "unstructured",
|
||||||
|
"config": {
|
||||||
|
"chunk_method": "fixed",
|
||||||
|
"chunk_size": 200,
|
||||||
|
"chunk_overlap": 0,
|
||||||
|
"min_chunk_size": 20,
|
||||||
|
"preprocess_options": ["clean_invalid_content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).json()["data"]["id"]
|
||||||
|
raw = _minimal_pdf_pages(
|
||||||
|
"ACME Internal Manual\nBody page one keeps this guidance and explanation.",
|
||||||
|
"ACME Internal Manual\nContents\n"
|
||||||
|
"Chapter One........3\nChapter Two........4\nAppendix........5",
|
||||||
|
"ACME Internal Manual\n1.1 Policy........6\n1.2 Approval........7\n1.3 Archive........8",
|
||||||
|
"ACME Internal Manual\nBody page four keeps operational details and examples.",
|
||||||
|
"ACME Internal Manual\nBody page five keeps the final effective-date clause.",
|
||||||
|
)
|
||||||
|
uploaded = client.post(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files",
|
||||||
|
files={"files": ("manual.pdf", raw, "application/pdf")},
|
||||||
|
).json()["data"]["files"][0]
|
||||||
|
source_content = client.get(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files/{uploaded['id']}/content"
|
||||||
|
).json()["data"]["content"]
|
||||||
|
|
||||||
|
built = client.post(f"/modelTF/data-process/{task_id}/preview/build")
|
||||||
|
|
||||||
|
assert built.status_code == 200
|
||||||
|
items = built.json()["data"]["items"]
|
||||||
|
assert items
|
||||||
|
edited = "\n".join(item["edited_content"] for item in items)
|
||||||
|
assert "ACME Internal Manual" not in edited
|
||||||
|
assert "Contents" not in edited
|
||||||
|
assert "Chapter One" not in edited
|
||||||
|
assert "1.2 Approval" not in edited
|
||||||
|
assert "Body page one" in edited
|
||||||
|
assert "Body page five" in edited
|
||||||
|
assert all(
|
||||||
|
item["original_content"]
|
||||||
|
== source_content[item["source_start"] : item["source_end"]]
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_raw_inline_preview_rejects_non_pdf_source(tmp_path: Path) -> None:
|
def test_raw_inline_preview_rejects_non_pdf_source(tmp_path: Path) -> None:
|
||||||
client, _, _ = make_client(tmp_path)
|
client, _, _ = make_client(tmp_path)
|
||||||
task_id = client.post(
|
task_id = client.post(
|
||||||
@@ -1450,6 +1536,51 @@ def test_every_unstructured_preprocess_option_changes_preview_behavior() -> None
|
|||||||
assert "[EMAIL]" in masked["edited_content"]
|
assert "[EMAIL]" in masked["edited_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_noise_cleaning_preserves_original_offsets_and_can_be_disabled() -> None:
|
||||||
|
source_text = normalize_text("重复页眉\n这是应保留的 PDF 正文内容,用于生成训练数据。")
|
||||||
|
source = {
|
||||||
|
"id": "pdf-source",
|
||||||
|
"name": "manual.pdf",
|
||||||
|
"file_format": "pdf",
|
||||||
|
"content": source_text,
|
||||||
|
"document_noise_spans": (
|
||||||
|
DocumentNoiseSpan(0, len("重复页眉"), "repeated_margin"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"chunk_method": "fixed",
|
||||||
|
"chunk_size": 200,
|
||||||
|
"chunk_overlap": 0,
|
||||||
|
"min_chunk_size": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned_items = data_process_endpoint._build_preview_items(
|
||||||
|
{
|
||||||
|
"process_type": "unstructured",
|
||||||
|
"config": {**config, "preprocess_options": ["clean_invalid_content"]},
|
||||||
|
},
|
||||||
|
[source],
|
||||||
|
)
|
||||||
|
original_items = data_process_endpoint._build_preview_items(
|
||||||
|
{
|
||||||
|
"process_type": "unstructured",
|
||||||
|
"config": {**config, "preprocess_options": []},
|
||||||
|
},
|
||||||
|
[source],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(cleaned_items) == 1
|
||||||
|
cleaned = cleaned_items[0]
|
||||||
|
assert cleaned["original_content"] == source_text[
|
||||||
|
cleaned["source_start"] : cleaned["source_end"]
|
||||||
|
]
|
||||||
|
assert "重复页眉" not in cleaned["edited_content"]
|
||||||
|
assert "PDF 正文内容" in cleaned["edited_content"]
|
||||||
|
assert cleaned["status"] == "modified"
|
||||||
|
assert "document_noise_removed" in cleaned["quality_score"]["preprocess_flags"]
|
||||||
|
assert "重复页眉" in original_items[0]["edited_content"]
|
||||||
|
|
||||||
|
|
||||||
def test_merge_short_content_applies_across_adjacent_structure_sections() -> None:
|
def test_merge_short_content_applies_across_adjacent_structure_sections() -> None:
|
||||||
content = "\n".join(f"{index}. 小节{index}\n内容{index}。" for index in range(1, 9))
|
content = "\n".join(f"{index}. 小节{index}\n内容{index}。" for index in range(1, 9))
|
||||||
items = _preview_task(
|
items = _preview_task(
|
||||||
|
|||||||
Reference in New Issue
Block a user