2026-07-23 15:10:13 +08:00
|
|
|
|
"""数据处理模块使用的无副作用算法。
|
|
|
|
|
|
|
|
|
|
|
|
本模块不访问数据库、文件系统或网络,便于 API、后台任务和测试共同复用。
|
2026-07-25 18:00:21 +08:00
|
|
|
|
所有偏移量均为 Python 字符串偏移量。
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
import json
|
2026-07-24 11:27:51 +08:00
|
|
|
|
import math
|
2026-07-23 15:10:13 +08:00
|
|
|
|
import re
|
|
|
|
|
|
import unicodedata
|
2026-07-24 11:27:51 +08:00
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
from collections import Counter
|
2026-07-23 15:10:13 +08:00
|
|
|
|
from collections.abc import Iterable, Mapping, Sequence
|
2026-07-24 11:27:51 +08:00
|
|
|
|
from copy import deepcopy
|
2026-07-23 15:10:13 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-07-24 11:27:51 +08:00
|
|
|
|
from datetime import date, datetime, time
|
2026-07-30 16:53:54 +08:00
|
|
|
|
from decimal import Decimal
|
2026-07-24 11:27:51 +08:00
|
|
|
|
from pathlib import Path, PurePosixPath
|
2026-07-23 15:10:13 +08:00
|
|
|
|
from typing import Any, Literal
|
2026-07-24 11:27:51 +08:00
|
|
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
|
|
|
|
|
|
|
|
from docx import Document
|
|
|
|
|
|
from docx.oxml.table import CT_Tbl
|
|
|
|
|
|
from docx.oxml.text.paragraph import CT_P
|
|
|
|
|
|
from docx.table import Table
|
|
|
|
|
|
from docx.text.paragraph import Paragraph
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
from openpyxl.utils.cell import range_boundaries
|
|
|
|
|
|
from pptx import Presentation
|
|
|
|
|
|
from pypdf import PdfReader
|
|
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
from app.modules.data_process.constants import MAX_QA_PAIRS_PER_ITEM
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
TextFormat = Literal[
|
|
|
|
|
|
"json",
|
|
|
|
|
|
"jsonl",
|
|
|
|
|
|
"csv",
|
|
|
|
|
|
"markdown",
|
|
|
|
|
|
"txt",
|
|
|
|
|
|
"pdf",
|
|
|
|
|
|
"docx",
|
|
|
|
|
|
"xlsx",
|
|
|
|
|
|
"pptx",
|
|
|
|
|
|
]
|
2026-07-23 15:10:13 +08:00
|
|
|
|
DatasetSplit = Literal["train", "validation", "test"]
|
2026-07-24 11:27:51 +08:00
|
|
|
|
StructuredPreprocessOption = Literal[
|
|
|
|
|
|
"clean_invalid",
|
|
|
|
|
|
"detect_structure",
|
|
|
|
|
|
"deduplicate",
|
|
|
|
|
|
"normalize_format",
|
|
|
|
|
|
"filter_anomaly",
|
|
|
|
|
|
"desensitize",
|
|
|
|
|
|
]
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
SUPPORTED_TEXT_FORMATS: tuple[TextFormat, ...] = (
|
|
|
|
|
|
"json",
|
|
|
|
|
|
"jsonl",
|
|
|
|
|
|
"csv",
|
|
|
|
|
|
"markdown",
|
|
|
|
|
|
"txt",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"pdf",
|
|
|
|
|
|
"docx",
|
|
|
|
|
|
"xlsx",
|
|
|
|
|
|
"pptx",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_FORMAT_ALIASES: dict[str, TextFormat] = {
|
|
|
|
|
|
"json": "json",
|
|
|
|
|
|
"jsonl": "jsonl",
|
|
|
|
|
|
"ndjson": "jsonl",
|
|
|
|
|
|
"csv": "csv",
|
|
|
|
|
|
"tsv": "csv",
|
|
|
|
|
|
"md": "markdown",
|
|
|
|
|
|
"markdown": "markdown",
|
|
|
|
|
|
"txt": "txt",
|
|
|
|
|
|
"text": "txt",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"pdf": "pdf",
|
|
|
|
|
|
"docx": "docx",
|
|
|
|
|
|
"xlsx": "xlsx",
|
|
|
|
|
|
"pptx": "pptx",
|
|
|
|
|
|
}
|
|
|
|
|
|
_LEGACY_OFFICE_FORMATS: dict[str, str] = {
|
|
|
|
|
|
"doc": "docx",
|
|
|
|
|
|
"xls": "xlsx",
|
|
|
|
|
|
"ppt": "pptx",
|
|
|
|
|
|
}
|
|
|
|
|
|
_OFFICE_OPEN_XML_FORMATS = {"docx", "xlsx", "pptx"}
|
|
|
|
|
|
_MAX_ARCHIVE_ENTRIES = 10_000
|
|
|
|
|
|
_MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024
|
|
|
|
|
|
_MAX_ARCHIVE_ENTRY_BYTES = 128 * 1024 * 1024
|
|
|
|
|
|
_MAX_ARCHIVE_COMPRESSION_RATIO = 200
|
|
|
|
|
|
_MAX_EXTRACTED_TEXT_CHARS = 20_000_000
|
|
|
|
|
|
_MAX_PDF_PAGES = 2_000
|
|
|
|
|
|
_MAX_PRESENTATION_SLIDES = 2_000
|
|
|
|
|
|
_MAX_WORKBOOK_SHEETS = 100
|
|
|
|
|
|
_MAX_WORKBOOK_ROWS = 100_000
|
|
|
|
|
|
_MAX_WORKBOOK_SCANNED_ROWS = 200_000
|
|
|
|
|
|
_MAX_WORKBOOK_COLUMNS = 256
|
|
|
|
|
|
_MAX_WORKBOOK_CELLS = 2_000_000
|
|
|
|
|
|
_MAX_WORKBOOK_HEADER_ROWS = 8
|
|
|
|
|
|
_MAX_WORKBOOK_HEADER_SCAN_ROWS = 64
|
|
|
|
|
|
_MAX_WORKBOOK_MERGED_RANGES = 100_000
|
|
|
|
|
|
_MAX_STRUCTURED_FIELDS = 1_024
|
|
|
|
|
|
_MAX_STRUCTURED_DEPTH = 16
|
2026-07-30 16:53:54 +08:00
|
|
|
|
_MAX_JSON_DEPTH = 64
|
2026-07-24 11:27:51 +08:00
|
|
|
|
_MAX_ANOMALY_TEXT_CHARS = 1_000_000
|
|
|
|
|
|
_STRUCTURED_OPTIONS = {
|
|
|
|
|
|
"clean_invalid",
|
|
|
|
|
|
"detect_structure",
|
|
|
|
|
|
"deduplicate",
|
|
|
|
|
|
"normalize_format",
|
|
|
|
|
|
"filter_anomaly",
|
|
|
|
|
|
"desensitize",
|
|
|
|
|
|
}
|
|
|
|
|
|
_IDENTITY_FIELD_PATTERN = re.compile(r"(?:^|[._])(?:id|uuid|key|code)$|(?:^|[._]).+_id$")
|
|
|
|
|
|
_MOJIBAKE_MARKERS = ("\ufffd", "锟斤拷", "烫烫烫", "屯屯屯", "Ã", "Â", "â€")
|
2026-07-30 16:53:54 +08:00
|
|
|
|
_JSON_RECORD_ARRAY_KEYS = ("records", "data", "items", "rows")
|
|
|
|
|
|
_JSON_ENVELOPE_KEYS = ("response", "payload")
|
|
|
|
|
|
_JSON_WRAPPER_METADATA_KEYS = frozenset(
|
|
|
|
|
|
{
|
|
|
|
|
|
"page",
|
|
|
|
|
|
"page_size",
|
|
|
|
|
|
"pageSize",
|
|
|
|
|
|
"per_page",
|
|
|
|
|
|
"perPage",
|
|
|
|
|
|
"total",
|
|
|
|
|
|
"total_count",
|
|
|
|
|
|
"totalCount",
|
|
|
|
|
|
"count",
|
|
|
|
|
|
"offset",
|
|
|
|
|
|
"limit",
|
|
|
|
|
|
"cursor",
|
|
|
|
|
|
"next_cursor",
|
|
|
|
|
|
"nextCursor",
|
|
|
|
|
|
"has_more",
|
|
|
|
|
|
"hasMore",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
_JSON_RESPONSE_METADATA_KEYS = _JSON_WRAPPER_METADATA_KEYS | {
|
|
|
|
|
|
"success",
|
|
|
|
|
|
"status",
|
|
|
|
|
|
"code",
|
|
|
|
|
|
"message",
|
|
|
|
|
|
"error",
|
|
|
|
|
|
}
|
2026-07-24 11:27:51 +08:00
|
|
|
|
_NAME_FIELD_NAMES = {
|
|
|
|
|
|
"name",
|
|
|
|
|
|
"full_name",
|
|
|
|
|
|
"fullname",
|
|
|
|
|
|
"real_name",
|
|
|
|
|
|
"contact_name",
|
|
|
|
|
|
"customer_name",
|
|
|
|
|
|
"recipient_name",
|
|
|
|
|
|
"姓名",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"中文姓名",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"真实姓名",
|
|
|
|
|
|
"联系人",
|
|
|
|
|
|
"联系人姓名",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"客户姓名",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"收件人",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"收件人姓名",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_EMAIL_PATTERN = re.compile(
|
|
|
|
|
|
r"(?<![\w.+-])[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
|
|
|
|
|
|
r"@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
|
|
|
|
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+(?![\w.-])"
|
|
|
|
|
|
)
|
|
|
|
|
|
_PHONE_PATTERN = re.compile(r"(?<!\d)(?:(?:\+|00)?86[-\s]?)?1[3-9]\d{9}(?!\d)")
|
|
|
|
|
|
_ID_CARD_PATTERN = re.compile(r"(?<!\d)(?:\d{17}[\dXx]|\d{15})(?!\d)")
|
2026-07-24 11:27:51 +08:00
|
|
|
|
_CHINESE_NAME_CONTEXT_PATTERN = re.compile(
|
|
|
|
|
|
r"(?P<label>姓名|真实姓名|联系人(?:姓名)?|收件人)"
|
|
|
|
|
|
r"(?P<separator>\s*(?:[::=]|为)\s*|\s+)"
|
|
|
|
|
|
r"(?P<name>[\u3400-\u4dbf\u4e00-\u9fff·]{2,8})"
|
|
|
|
|
|
)
|
|
|
|
|
|
_ENGLISH_NAME_CONTEXT_PATTERN = re.compile(
|
|
|
|
|
|
r"(?im)(?P<label>full\s+name|contact\s+name|name)"
|
|
|
|
|
|
r"(?P<separator>\s*[:=]\s*)"
|
|
|
|
|
|
r"(?P<name>[A-Za-z][A-Za-z'’-]*(?:[ \t]+[A-Za-z][A-Za-z'’-]*){0,3})"
|
2026-07-23 15:10:13 +08:00
|
|
|
|
)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
_TOKEN_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]")
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class ParsedText:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"""文本、文档或工作簿的统一解析结果。"""
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
format: TextFormat
|
|
|
|
|
|
text: str
|
|
|
|
|
|
records: tuple[dict[str, Any], ...]
|
2026-07-30 16:53:54 +08:00
|
|
|
|
record_locators: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class ProcessedStructuredRecord:
|
|
|
|
|
|
"""保留原始记录索引的结构化预处理结果。"""
|
|
|
|
|
|
|
|
|
|
|
|
source_index: int
|
|
|
|
|
|
record: dict[str, Any]
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class PdfPageText:
|
|
|
|
|
|
"""PDF 物理页在统一提取文本中的字符范围。"""
|
|
|
|
|
|
|
|
|
|
|
|
page_number: int
|
|
|
|
|
|
text: str
|
|
|
|
|
|
source_start: int
|
|
|
|
|
|
source_end: int
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 15:05:39 +08:00
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class DocumentNoiseSpan:
|
|
|
|
|
|
"""PDF 中可安全从展示内容移除的文本范围。"""
|
|
|
|
|
|
|
|
|
|
|
|
start: int
|
|
|
|
|
|
end: int
|
|
|
|
|
|
kind: Literal["page_number", "repeated_margin", "table_of_contents"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class QualityScore:
|
|
|
|
|
|
"""标准 instruction/input/output 记录的可解释质量分。"""
|
|
|
|
|
|
|
|
|
|
|
|
overall: float
|
|
|
|
|
|
completeness: float
|
|
|
|
|
|
length: float
|
|
|
|
|
|
readability: float
|
|
|
|
|
|
relevance: float
|
|
|
|
|
|
duplicate: float
|
|
|
|
|
|
is_valid: bool
|
|
|
|
|
|
flags: tuple[str, ...]
|
|
|
|
|
|
fingerprint: str
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class DocumentHeading:
|
|
|
|
|
|
"""文档标题的位置和层级。"""
|
|
|
|
|
|
|
|
|
|
|
|
level: int
|
|
|
|
|
|
title: str
|
|
|
|
|
|
line_number: int
|
|
|
|
|
|
start: int
|
|
|
|
|
|
end: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class DocumentStructure:
|
|
|
|
|
|
"""无需外部模型即可复现的文档结构摘要。"""
|
|
|
|
|
|
|
|
|
|
|
|
line_count: int
|
|
|
|
|
|
paragraph_count: int
|
|
|
|
|
|
headings: tuple[DocumentHeading, ...]
|
|
|
|
|
|
code_block_count: int
|
|
|
|
|
|
table_block_count: int
|
|
|
|
|
|
list_block_count: int
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def decode_utf8(raw: bytes | bytearray | memoryview | str) -> str:
|
|
|
|
|
|
"""严格解码 UTF-8 文本,并移除可选 BOM。
|
|
|
|
|
|
|
|
|
|
|
|
不使用 ``errors='replace'``,避免上传内容损坏后仍被静默接收。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(raw, str):
|
|
|
|
|
|
return raw.removeprefix("\ufeff")
|
|
|
|
|
|
if not isinstance(raw, (bytes, bytearray, memoryview)):
|
|
|
|
|
|
raise TypeError("raw must be bytes-like or str")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return bytes(raw).decode("utf-8-sig")
|
|
|
|
|
|
except UnicodeDecodeError as exc:
|
|
|
|
|
|
raise ValueError(f"content is not valid UTF-8 at byte {exc.start}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_utf8_text(raw: bytes | bytearray | memoryview | str) -> str:
|
|
|
|
|
|
"""``decode_utf8`` 的语义化别名,供上传服务直接调用。"""
|
|
|
|
|
|
|
|
|
|
|
|
return decode_utf8(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_text(text: str) -> str:
|
|
|
|
|
|
"""规范 Unicode、换行和行尾空白,同时保留段落结构。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
|
raise TypeError("text must be str")
|
|
|
|
|
|
normalized = unicodedata.normalize("NFKC", text.removeprefix("\ufeff"))
|
|
|
|
|
|
normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
normalized = "".join(
|
|
|
|
|
|
char
|
|
|
|
|
|
for char in normalized
|
|
|
|
|
|
if char in {"\n", "\t"} or not unicodedata.category(char).startswith("C")
|
|
|
|
|
|
)
|
|
|
|
|
|
lines = [re.sub(r"[\t \f\v]+$", "", line) for line in normalized.split("\n")]
|
|
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_format(value: str | None) -> TextFormat | None:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
normalized = value.strip().lower().removeprefix(".")
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if normalized in _LEGACY_OFFICE_FORMATS:
|
|
|
|
|
|
replacement = _LEGACY_OFFICE_FORMATS[normalized]
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"legacy .{normalized} format is not supported; "
|
|
|
|
|
|
f"convert the file to .{replacement} and upload again"
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
try:
|
|
|
|
|
|
return _FORMAT_ALIASES[normalized]
|
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
|
raise ValueError(f"unsupported text format: {value}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_text_format(
|
|
|
|
|
|
*,
|
|
|
|
|
|
filename: str | None = None,
|
|
|
|
|
|
text: str = "",
|
|
|
|
|
|
file_format: str | None = None,
|
|
|
|
|
|
) -> TextFormat:
|
|
|
|
|
|
"""按显式格式、扩展名和内容特征依次识别文本格式。"""
|
|
|
|
|
|
|
|
|
|
|
|
explicit = _normalize_format(file_format)
|
|
|
|
|
|
if explicit:
|
|
|
|
|
|
return explicit
|
|
|
|
|
|
|
|
|
|
|
|
if filename:
|
|
|
|
|
|
suffix = Path(filename).suffix.lower().removeprefix(".")
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if suffix in _LEGACY_OFFICE_FORMATS:
|
|
|
|
|
|
replacement = _LEGACY_OFFICE_FORMATS[suffix]
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"legacy .{suffix} format is not supported; "
|
|
|
|
|
|
f"convert the file to .{replacement} and upload again"
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
detected = _FORMAT_ALIASES.get(suffix)
|
|
|
|
|
|
if detected:
|
|
|
|
|
|
return detected
|
|
|
|
|
|
|
|
|
|
|
|
stripped = text.strip()
|
|
|
|
|
|
if stripped:
|
|
|
|
|
|
if stripped[0] in "[{":
|
|
|
|
|
|
try:
|
|
|
|
|
|
json.loads(stripped)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "json"
|
|
|
|
|
|
|
|
|
|
|
|
nonempty_lines = [line for line in stripped.splitlines() if line.strip()]
|
|
|
|
|
|
if len(nonempty_lines) > 1:
|
|
|
|
|
|
try:
|
|
|
|
|
|
for line in nonempty_lines:
|
|
|
|
|
|
json.loads(line)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "jsonl"
|
|
|
|
|
|
|
|
|
|
|
|
if re.search(r"(?m)^(?:#{1,6}\s+|```|~~~)", stripped) or re.search(
|
|
|
|
|
|
r"(?m)^\s*\|.+\|\s*$", stripped
|
|
|
|
|
|
):
|
|
|
|
|
|
return "markdown"
|
|
|
|
|
|
|
|
|
|
|
|
sample = stripped[:8192]
|
|
|
|
|
|
try:
|
|
|
|
|
|
dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
|
|
|
|
|
|
rows = list(csv.reader(io.StringIO(sample), dialect))
|
|
|
|
|
|
if len(rows) >= 2 and len(rows[0]) >= 2:
|
|
|
|
|
|
return "csv"
|
|
|
|
|
|
except csv.Error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return "txt"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def _binary_bytes(
|
|
|
|
|
|
raw: bytes | bytearray | memoryview | str,
|
|
|
|
|
|
file_format: TextFormat,
|
|
|
|
|
|
) -> bytes:
|
|
|
|
|
|
if isinstance(raw, str):
|
|
|
|
|
|
raise ValueError(f"{file_format.upper()} content must be uploaded as binary data")
|
|
|
|
|
|
if not isinstance(raw, (bytes, bytearray, memoryview)):
|
|
|
|
|
|
raise TypeError("raw must be bytes-like or str")
|
|
|
|
|
|
return bytes(raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_office_archive(raw: bytes, file_format: TextFormat) -> None:
|
|
|
|
|
|
"""在交给 Office 解析库前限制 ZIP 包规模并拒绝活动 XML。"""
|
|
|
|
|
|
|
|
|
|
|
|
required_members = {
|
|
|
|
|
|
"docx": {"[Content_Types].xml", "word/document.xml"},
|
|
|
|
|
|
"xlsx": {"[Content_Types].xml", "xl/workbook.xml"},
|
|
|
|
|
|
"pptx": {"[Content_Types].xml", "ppt/presentation.xml"},
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
|
|
|
|
|
members = archive.infolist()
|
|
|
|
|
|
if len(members) > _MAX_ARCHIVE_ENTRIES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive contains too many entries "
|
|
|
|
|
|
f"(limit {_MAX_ARCHIVE_ENTRIES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
names: set[str] = set()
|
|
|
|
|
|
total_size = 0
|
|
|
|
|
|
for member in members:
|
|
|
|
|
|
path = PurePosixPath(member.filename.replace("\\", "/"))
|
|
|
|
|
|
if path.is_absolute() or ".." in path.parts:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive contains an unsafe member path"
|
|
|
|
|
|
)
|
|
|
|
|
|
if member.filename in names:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive contains duplicate member names"
|
|
|
|
|
|
)
|
|
|
|
|
|
names.add(member.filename)
|
|
|
|
|
|
if member.flag_bits & 0x1:
|
|
|
|
|
|
raise ValueError(f"encrypted {file_format.upper()} files are not supported")
|
|
|
|
|
|
if member.is_dir():
|
|
|
|
|
|
continue
|
|
|
|
|
|
if member.file_size > _MAX_ARCHIVE_ENTRY_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive entry exceeds "
|
|
|
|
|
|
f"{_MAX_ARCHIVE_ENTRY_BYTES} bytes"
|
|
|
|
|
|
)
|
|
|
|
|
|
total_size += member.file_size
|
|
|
|
|
|
if total_size > _MAX_ARCHIVE_UNCOMPRESSED_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive expands beyond "
|
|
|
|
|
|
f"{_MAX_ARCHIVE_UNCOMPRESSED_BYTES} bytes"
|
|
|
|
|
|
)
|
|
|
|
|
|
if member.file_size >= 1024 * 1024:
|
|
|
|
|
|
if member.compress_size <= 0:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive has an unsafe compression ratio"
|
|
|
|
|
|
)
|
|
|
|
|
|
ratio = member.file_size / member.compress_size
|
|
|
|
|
|
if ratio > _MAX_ARCHIVE_COMPRESSION_RATIO:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive has an unsafe compression ratio"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
missing = required_members[file_format] - names
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"invalid {file_format.upper()} package: missing "
|
|
|
|
|
|
f"{', '.join(sorted(missing))}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for member in members:
|
|
|
|
|
|
if member.is_dir() or not member.filename.lower().endswith((".xml", ".rels")):
|
|
|
|
|
|
continue
|
|
|
|
|
|
with archive.open(member) as stream:
|
|
|
|
|
|
prefix = stream.read(min(member.file_size, 1024 * 1024)).upper()
|
|
|
|
|
|
if b"<!DOCTYPE" in prefix or b"<!ENTITY" in prefix:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"{file_format.upper()} archive contains unsupported active XML"
|
|
|
|
|
|
)
|
|
|
|
|
|
except zipfile.BadZipFile as exc:
|
|
|
|
|
|
raise ValueError(f"invalid {file_format.upper()} file: not an Office ZIP package") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _append_bounded_text(parts: list[str], value: Any, total: int) -> int:
|
|
|
|
|
|
text = normalize_text(str(value or ""))
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return total
|
|
|
|
|
|
total += len(text)
|
|
|
|
|
|
if total > _MAX_EXTRACTED_TEXT_CHARS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"extracted document text exceeds {_MAX_EXTRACTED_TEXT_CHARS} characters"
|
|
|
|
|
|
)
|
|
|
|
|
|
parts.append(text)
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_pdf_page_texts(raw: bytes) -> tuple[PdfPageText, ...]:
|
|
|
|
|
|
"""提取 PDF 各页文本,并保留与切片字符偏移一致的页范围。"""
|
|
|
|
|
|
|
|
|
|
|
|
if b"%PDF-" not in raw[:1024]:
|
|
|
|
|
|
raise ValueError("invalid PDF file: missing PDF header")
|
|
|
|
|
|
try:
|
|
|
|
|
|
reader = PdfReader(io.BytesIO(raw), strict=True)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"invalid PDF file: {exc}") from exc
|
|
|
|
|
|
if reader.is_encrypted and not reader.decrypt(""):
|
|
|
|
|
|
raise ValueError("password-protected PDF files are not supported")
|
|
|
|
|
|
if len(reader.pages) > _MAX_PDF_PAGES:
|
|
|
|
|
|
raise ValueError(f"PDF contains too many pages (limit {_MAX_PDF_PAGES})")
|
|
|
|
|
|
|
|
|
|
|
|
pages: list[PdfPageText] = []
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
has_text = False
|
|
|
|
|
|
for page_number, page in enumerate(reader.pages, start=1):
|
|
|
|
|
|
try:
|
|
|
|
|
|
text = normalize_text(page.extract_text() or "")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"failed to extract text from PDF page {page_number}: {exc}"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
pages.append(
|
|
|
|
|
|
PdfPageText(
|
|
|
|
|
|
page_number=page_number,
|
|
|
|
|
|
text="",
|
|
|
|
|
|
source_start=total,
|
|
|
|
|
|
source_end=total,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
if has_text:
|
|
|
|
|
|
total += 2
|
|
|
|
|
|
start = total
|
|
|
|
|
|
total += len(text)
|
|
|
|
|
|
if total > _MAX_EXTRACTED_TEXT_CHARS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"extracted document text exceeds {_MAX_EXTRACTED_TEXT_CHARS} characters"
|
|
|
|
|
|
)
|
|
|
|
|
|
pages.append(
|
|
|
|
|
|
PdfPageText(
|
|
|
|
|
|
page_number=page_number,
|
|
|
|
|
|
text=text,
|
|
|
|
|
|
source_start=start,
|
|
|
|
|
|
source_end=total,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
has_text = True
|
|
|
|
|
|
if not has_text:
|
|
|
|
|
|
raise ValueError("PDF contains no extractable text; scanned PDF requires OCR")
|
|
|
|
|
|
return tuple(pages)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 15:05:39 +08:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def _extract_pdf_text(raw: bytes) -> str:
|
|
|
|
|
|
return "\n\n".join(page.text for page in extract_pdf_page_texts(raw) if page.text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_docx_text(raw: bytes) -> str:
|
|
|
|
|
|
_validate_office_archive(raw, "docx")
|
|
|
|
|
|
try:
|
|
|
|
|
|
document = Document(io.BytesIO(raw))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"invalid DOCX file: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for child in document.element.body.iterchildren():
|
|
|
|
|
|
if isinstance(child, CT_P):
|
|
|
|
|
|
total = _append_bounded_text(parts, Paragraph(child, document).text, total)
|
|
|
|
|
|
continue
|
|
|
|
|
|
if isinstance(child, CT_Tbl):
|
|
|
|
|
|
table = Table(child, document)
|
|
|
|
|
|
for row in table.rows:
|
|
|
|
|
|
cells = [normalize_text(cell.text) for cell in row.cells]
|
|
|
|
|
|
total = _append_bounded_text(parts, "\t".join(cells), total)
|
|
|
|
|
|
return normalize_text("\n\n".join(parts))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _presentation_shape_text(shape: Any) -> list[str]:
|
|
|
|
|
|
if getattr(shape, "has_table", False):
|
|
|
|
|
|
return [
|
|
|
|
|
|
"\t".join(normalize_text(cell.text) for cell in row.cells)
|
|
|
|
|
|
for row in shape.table.rows
|
|
|
|
|
|
]
|
|
|
|
|
|
if getattr(shape, "has_text_frame", False):
|
|
|
|
|
|
return [shape.text]
|
|
|
|
|
|
child_shapes = getattr(shape, "shapes", None)
|
|
|
|
|
|
if child_shapes is not None:
|
|
|
|
|
|
values: list[str] = []
|
|
|
|
|
|
for child in child_shapes:
|
|
|
|
|
|
values.extend(_presentation_shape_text(child))
|
|
|
|
|
|
return values
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_pptx_text(raw: bytes) -> str:
|
|
|
|
|
|
_validate_office_archive(raw, "pptx")
|
|
|
|
|
|
try:
|
|
|
|
|
|
presentation = Presentation(io.BytesIO(raw))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"invalid PPTX file: {exc}") from exc
|
|
|
|
|
|
if len(presentation.slides) > _MAX_PRESENTATION_SLIDES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"PPTX contains too many slides (limit {_MAX_PRESENTATION_SLIDES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for slide in presentation.slides:
|
|
|
|
|
|
slide_parts: list[str] = []
|
|
|
|
|
|
for shape in slide.shapes:
|
|
|
|
|
|
slide_parts.extend(_presentation_shape_text(shape))
|
|
|
|
|
|
total = _append_bounded_text(parts, "\n".join(slide_parts), total)
|
|
|
|
|
|
return normalize_text("\n\n".join(parts))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_spreadsheet_value(value: Any) -> Any:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return normalize_text(value)
|
|
|
|
|
|
if isinstance(value, (datetime, date, time)):
|
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
|
if isinstance(value, (bool, int, float)):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return normalize_text(str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xml_local_name(tag: str) -> str:
|
|
|
|
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_xlsx_relationship_target(
|
|
|
|
|
|
archive: zipfile.ZipFile,
|
|
|
|
|
|
target: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
source_part: str = "xl/workbook.xml",
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""按 OPC URI 规则解析内部关系目标,并保证结果仍位于 ZIP 根内。"""
|
|
|
|
|
|
|
|
|
|
|
|
raw_target = target.strip()
|
|
|
|
|
|
if (
|
|
|
|
|
|
raw_target != target
|
|
|
|
|
|
or not raw_target
|
|
|
|
|
|
or "\\" in raw_target
|
|
|
|
|
|
or any(unicodedata.category(char).startswith("C") for char in raw_target)
|
|
|
|
|
|
or re.search(r"%(?![0-9A-Fa-f]{2})", raw_target)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlsplit(raw_target)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path") from exc
|
|
|
|
|
|
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
if re.search(r"%(?:2[fF]|5[cC]|0{2})", parsed.path):
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
try:
|
|
|
|
|
|
decoded_path = unquote(parsed.path, encoding="utf-8", errors="strict")
|
|
|
|
|
|
except UnicodeDecodeError as exc:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path") from exc
|
|
|
|
|
|
if not decoded_path or "\\" in decoded_path or "\x00" in decoded_path or "%" in decoded_path:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
|
|
|
|
|
|
parts = [] if decoded_path.startswith("/") else list(PurePosixPath(source_part).parent.parts)
|
|
|
|
|
|
for part in decoded_path.lstrip("/").split("/"):
|
|
|
|
|
|
if part in {"", "."}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if part == "..":
|
|
|
|
|
|
if not parts:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
parts.pop()
|
|
|
|
|
|
continue
|
|
|
|
|
|
if unicodedata.category(part[0]).startswith("C") or any(
|
|
|
|
|
|
unicodedata.category(char).startswith("C") for char in part
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
parts.append(part)
|
|
|
|
|
|
if not parts:
|
|
|
|
|
|
raise ValueError("XLSX workbook contains an unsafe worksheet path")
|
|
|
|
|
|
|
|
|
|
|
|
member_name = "/".join(parts)
|
|
|
|
|
|
try:
|
|
|
|
|
|
member = archive.getinfo(member_name)
|
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet relationship target does not exist: {member_name}"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
if member.is_dir():
|
|
|
|
|
|
raise ValueError("XLSX worksheet relationship target must be a file")
|
|
|
|
|
|
return member_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rewrite_xlsx_workbook_relationships(
|
|
|
|
|
|
raw: bytes,
|
|
|
|
|
|
replacements: Mapping[str, str],
|
|
|
|
|
|
) -> bytes:
|
|
|
|
|
|
"""把已验证的 worksheet Target 改为解析库稳定支持的包内绝对路径。"""
|
|
|
|
|
|
|
|
|
|
|
|
relationships_member = "xl/_rels/workbook.xml.rels"
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as source, zipfile.ZipFile(output, "w") as target:
|
|
|
|
|
|
target.comment = source.comment
|
|
|
|
|
|
for member in source.infolist():
|
|
|
|
|
|
if member.filename == relationships_member:
|
|
|
|
|
|
root = ET.fromstring(source.read(member))
|
|
|
|
|
|
pending = dict(replacements)
|
|
|
|
|
|
for element in root:
|
|
|
|
|
|
if _xml_local_name(element.tag) != "Relationship":
|
|
|
|
|
|
continue
|
|
|
|
|
|
relationship_id = element.attrib.get("Id")
|
|
|
|
|
|
if relationship_id in pending:
|
|
|
|
|
|
element.set("Target", pending.pop(relationship_id))
|
|
|
|
|
|
if pending:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"XLSX workbook relationship changed during normalization"
|
|
|
|
|
|
)
|
|
|
|
|
|
content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
|
|
|
|
target.writestr(member, content)
|
|
|
|
|
|
continue
|
|
|
|
|
|
if member.is_dir():
|
|
|
|
|
|
target.writestr(member, b"")
|
|
|
|
|
|
continue
|
|
|
|
|
|
with source.open(member) as source_stream, target.open(
|
|
|
|
|
|
member,
|
|
|
|
|
|
"w",
|
|
|
|
|
|
force_zip64=True,
|
|
|
|
|
|
) as target_stream:
|
|
|
|
|
|
while chunk := source_stream.read(1024 * 1024):
|
|
|
|
|
|
target_stream.write(chunk)
|
|
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xlsx_sheet_merge_ranges(
|
|
|
|
|
|
raw: bytes,
|
|
|
|
|
|
) -> tuple[
|
|
|
|
|
|
dict[str, tuple[tuple[int, int, int, int], ...]],
|
|
|
|
|
|
dict[str, str],
|
|
|
|
|
|
]:
|
|
|
|
|
|
"""流式读取 XLSX 合并单元格,不把工作表 XML 整体载入内存。"""
|
|
|
|
|
|
|
|
|
|
|
|
relationship_namespace = (
|
|
|
|
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
|
|
|
|
|
relationships: dict[str, tuple[str, str, str]] = {}
|
|
|
|
|
|
with archive.open("xl/_rels/workbook.xml.rels") as stream:
|
|
|
|
|
|
for _, element in ET.iterparse(stream, events=("end",)):
|
|
|
|
|
|
if _xml_local_name(element.tag) != "Relationship":
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
continue
|
|
|
|
|
|
relationship_id = element.attrib.get("Id")
|
|
|
|
|
|
target = element.attrib.get("Target")
|
|
|
|
|
|
target_mode = element.attrib.get("TargetMode", "Internal")
|
|
|
|
|
|
relationship_type = element.attrib.get("Type", "")
|
|
|
|
|
|
if relationship_id:
|
|
|
|
|
|
if relationship_id in relationships:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"XLSX workbook contains duplicate relationship identifiers"
|
|
|
|
|
|
)
|
|
|
|
|
|
relationships[relationship_id] = (
|
|
|
|
|
|
target or "",
|
|
|
|
|
|
target_mode,
|
|
|
|
|
|
relationship_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
|
|
|
|
|
|
sheet_paths: dict[str, str] = {}
|
|
|
|
|
|
normalized_targets: dict[str, str] = {}
|
|
|
|
|
|
with archive.open("xl/workbook.xml") as stream:
|
|
|
|
|
|
for _, element in ET.iterparse(stream, events=("end",)):
|
|
|
|
|
|
if _xml_local_name(element.tag) != "sheet":
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
continue
|
|
|
|
|
|
title = element.attrib.get("name")
|
|
|
|
|
|
relationship_id = element.attrib.get(
|
|
|
|
|
|
f"{{{relationship_namespace}}}id"
|
|
|
|
|
|
)
|
|
|
|
|
|
if title and relationship_id:
|
|
|
|
|
|
relationship = relationships.get(relationship_id)
|
|
|
|
|
|
if relationship is None:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {title!r} references a missing relationship"
|
|
|
|
|
|
)
|
|
|
|
|
|
target, target_mode, relationship_type = relationship
|
|
|
|
|
|
if target_mode.strip().lower() != "internal":
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {title!r} uses an external relationship"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not relationship_type.endswith("/worksheet"):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {title!r} has an invalid relationship type"
|
|
|
|
|
|
)
|
|
|
|
|
|
member_name = _resolve_xlsx_relationship_target(
|
|
|
|
|
|
archive,
|
|
|
|
|
|
target,
|
|
|
|
|
|
)
|
|
|
|
|
|
sheet_paths[title] = member_name
|
|
|
|
|
|
canonical_target = f"/{member_name}"
|
|
|
|
|
|
if target != canonical_target:
|
|
|
|
|
|
normalized_targets[relationship_id] = canonical_target
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
|
|
|
|
|
|
result: dict[str, tuple[tuple[int, int, int, int], ...]] = {}
|
|
|
|
|
|
total_ranges = 0
|
|
|
|
|
|
for title, member_name in sheet_paths.items():
|
|
|
|
|
|
ranges: list[tuple[int, int, int, int]] = []
|
|
|
|
|
|
with archive.open(member_name) as stream:
|
|
|
|
|
|
for _, element in ET.iterparse(stream, events=("end",)):
|
|
|
|
|
|
if _xml_local_name(element.tag) != "mergeCell":
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
continue
|
|
|
|
|
|
reference = element.attrib.get("ref")
|
|
|
|
|
|
if reference:
|
|
|
|
|
|
try:
|
|
|
|
|
|
boundaries = range_boundaries(reference)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {title!r} has an invalid merged range"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
ranges.append(boundaries)
|
|
|
|
|
|
total_ranges += 1
|
|
|
|
|
|
if total_ranges > _MAX_WORKBOOK_MERGED_RANGES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"XLSX workbook contains too many merged ranges "
|
|
|
|
|
|
f"(limit {_MAX_WORKBOOK_MERGED_RANGES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
element.clear()
|
|
|
|
|
|
result[title] = tuple(ranges)
|
|
|
|
|
|
return result, normalized_targets
|
|
|
|
|
|
except (KeyError, ET.ParseError, zipfile.BadZipFile) as exc:
|
|
|
|
|
|
raise ValueError(f"invalid XLSX workbook structure: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xlsx_header_end_row(
|
|
|
|
|
|
first_row: int,
|
|
|
|
|
|
rows: Mapping[int, Sequence[Any]],
|
|
|
|
|
|
merged_ranges: Sequence[tuple[int, int, int, int]],
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""在固定深度内闭包表头合并关系,忽略越界或跨空行的可疑级联。"""
|
|
|
|
|
|
|
|
|
|
|
|
header_end = first_row
|
|
|
|
|
|
maximum_end = first_row + _MAX_WORKBOOK_HEADER_ROWS - 1
|
|
|
|
|
|
has_header_hierarchy = any(
|
|
|
|
|
|
max_column > min_column and min_row == first_row
|
|
|
|
|
|
for min_column, min_row, max_column, _ in merged_ranges
|
|
|
|
|
|
)
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
while changed:
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
for min_column, min_row, max_column, max_row in merged_ranges:
|
|
|
|
|
|
if min_row < first_row or min_row > header_end or max_row < first_row:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if max_column == min_column and not has_header_hierarchy:
|
|
|
|
|
|
continue
|
|
|
|
|
|
candidate = max_row + 1 if max_column > min_column else max_row
|
|
|
|
|
|
if candidate <= header_end or candidate > maximum_end:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if (
|
|
|
|
|
|
max_column > min_column
|
|
|
|
|
|
and len(_xlsx_nonempty_values(rows.get(min_row, ()))) < 2
|
|
|
|
|
|
and len(_xlsx_nonempty_values(rows.get(candidate, ()))) < 2
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if any(
|
|
|
|
|
|
not rows.get(row_number)
|
|
|
|
|
|
for row_number in range(header_end + 1, candidate + 1)
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
header_end = candidate
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
return header_end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xlsx_headers(
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
rows: Mapping[int, Sequence[Any]],
|
|
|
|
|
|
first_row: int,
|
|
|
|
|
|
header_end: int,
|
|
|
|
|
|
merged_ranges: Sequence[tuple[int, int, int, int]],
|
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
|
width = max((len(row) for row in rows.values()), default=0)
|
|
|
|
|
|
for min_column, min_row, max_column, max_row in merged_ranges:
|
|
|
|
|
|
if min_row <= header_end and max_row >= first_row:
|
|
|
|
|
|
width = max(width, max_column)
|
|
|
|
|
|
if width > _MAX_WORKBOOK_COLUMNS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {title!r} exceeds {_MAX_WORKBOOK_COLUMNS} columns"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
matrix = [
|
|
|
|
|
|
list(rows.get(row_number, ())) + [None] * (width - len(rows.get(row_number, ())))
|
|
|
|
|
|
for row_number in range(first_row, header_end + 1)
|
|
|
|
|
|
]
|
|
|
|
|
|
for min_column, min_row, max_column, max_row in merged_ranges:
|
|
|
|
|
|
if min_row > header_end or max_row < first_row:
|
|
|
|
|
|
continue
|
|
|
|
|
|
source_row = max(first_row, min_row) - first_row
|
|
|
|
|
|
source_column = min_column - 1
|
|
|
|
|
|
source = matrix[source_row][source_column]
|
|
|
|
|
|
for row_number in range(max(first_row, min_row), min(header_end, max_row) + 1):
|
|
|
|
|
|
for column_number in range(min_column, max_column + 1):
|
|
|
|
|
|
matrix[row_number - first_row][column_number - 1] = source
|
|
|
|
|
|
|
|
|
|
|
|
headers: list[str] = []
|
|
|
|
|
|
for column in range(width):
|
|
|
|
|
|
components: list[str] = []
|
|
|
|
|
|
for row in matrix:
|
|
|
|
|
|
component = normalize_text(str(row[column] or ""))
|
|
|
|
|
|
if component and (not components or component != components[-1]):
|
|
|
|
|
|
components.append(component)
|
|
|
|
|
|
header = ".".join(components)
|
|
|
|
|
|
if not header:
|
|
|
|
|
|
raise ValueError(f"XLSX worksheet {title!r} contains an empty header")
|
|
|
|
|
|
headers.append(header)
|
|
|
|
|
|
if len(set(headers)) != len(headers):
|
|
|
|
|
|
raise ValueError(f"XLSX worksheet {title!r} contains duplicate headers")
|
|
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_XLSX_REPORT_METADATA_PATTERN = re.compile(
|
|
|
|
|
|
r"^(?:报表|报告|标题|说明|备注|制表|统计|日期|时间|期间|"
|
|
|
|
|
|
r"report|title|note|remark|date|time|period)\b",
|
|
|
|
|
|
flags=re.IGNORECASE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _xlsx_nonempty_values(row: Sequence[Any]) -> list[Any]:
|
|
|
|
|
|
return [value for value in row if value not in {None, ""}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_xlsx_header_region(
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
rows: Mapping[int, Sequence[Any]],
|
|
|
|
|
|
merged_ranges: Sequence[tuple[int, int, int, int]],
|
|
|
|
|
|
) -> tuple[int, int, list[str]]:
|
|
|
|
|
|
"""从有界前缀中推断表头,区分报表说明、多级表头和正文合并。"""
|
|
|
|
|
|
|
|
|
|
|
|
ordered_rows = sorted(rows)
|
|
|
|
|
|
if not ordered_rows:
|
|
|
|
|
|
return 0, 0, []
|
|
|
|
|
|
first_nonempty_row = ordered_rows[0]
|
|
|
|
|
|
horizontal_merge_rows = {
|
|
|
|
|
|
min_row
|
|
|
|
|
|
for min_column, min_row, max_column, _ in merged_ranges
|
|
|
|
|
|
if max_column > min_column
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates: list[tuple[float, int, int, list[str]]] = []
|
|
|
|
|
|
for first_row in ordered_rows:
|
|
|
|
|
|
raw_values = _xlsx_nonempty_values(rows[first_row])
|
|
|
|
|
|
if not raw_values:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if len(raw_values) < 2 and first_row in horizontal_merge_rows:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
header_end = _xlsx_header_end_row(first_row, rows, merged_ranges)
|
|
|
|
|
|
header_rows = {
|
|
|
|
|
|
row_number: rows[row_number]
|
|
|
|
|
|
for row_number in range(first_row, header_end + 1)
|
|
|
|
|
|
if row_number in rows
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
headers = _xlsx_headers(
|
|
|
|
|
|
title,
|
|
|
|
|
|
header_rows,
|
|
|
|
|
|
first_row,
|
|
|
|
|
|
header_end,
|
|
|
|
|
|
merged_ranges,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
if header_end == first_row:
|
|
|
|
|
|
continue
|
|
|
|
|
|
header_end = first_row
|
|
|
|
|
|
try:
|
|
|
|
|
|
headers = _xlsx_headers(
|
|
|
|
|
|
title,
|
|
|
|
|
|
{first_row: rows[first_row]},
|
|
|
|
|
|
first_row,
|
|
|
|
|
|
first_row,
|
|
|
|
|
|
merged_ranges,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
data_rows = [rows[row_number] for row_number in ordered_rows if row_number > header_end]
|
|
|
|
|
|
if any(len(row) > len(headers) for row in data_rows):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
text_ratio = sum(isinstance(value, str) for value in raw_values) / len(raw_values)
|
|
|
|
|
|
score = text_ratio * 6 + min(len(raw_values), 4)
|
|
|
|
|
|
if data_rows:
|
|
|
|
|
|
first_data_values = _xlsx_nonempty_values(data_rows[0])
|
|
|
|
|
|
score += 4 * min(len(first_data_values), len(headers)) / len(headers)
|
|
|
|
|
|
if first_data_values:
|
|
|
|
|
|
score += (
|
|
|
|
|
|
2
|
|
|
|
|
|
* sum(
|
|
|
|
|
|
not isinstance(value, str)
|
|
|
|
|
|
for value in first_data_values
|
|
|
|
|
|
)
|
|
|
|
|
|
/ len(first_data_values)
|
|
|
|
|
|
)
|
|
|
|
|
|
first_label = normalize_text(str(raw_values[0]))
|
|
|
|
|
|
if len(raw_values) <= 2 and _XLSX_REPORT_METADATA_PATTERN.match(first_label):
|
|
|
|
|
|
score -= 8
|
|
|
|
|
|
candidates.append((score, first_row, header_end, headers))
|
|
|
|
|
|
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
first_row = first_nonempty_row
|
|
|
|
|
|
headers = _xlsx_headers(
|
|
|
|
|
|
title,
|
|
|
|
|
|
{first_row: rows[first_row]},
|
|
|
|
|
|
first_row,
|
|
|
|
|
|
first_row,
|
|
|
|
|
|
(),
|
|
|
|
|
|
)
|
|
|
|
|
|
return first_row, first_row, headers
|
|
|
|
|
|
_, first_row, header_end, headers = max(
|
|
|
|
|
|
candidates,
|
|
|
|
|
|
key=lambda candidate: (candidate[0], -candidate[1]),
|
|
|
|
|
|
)
|
|
|
|
|
|
return first_row, header_end, headers
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _extract_xlsx_records(
|
|
|
|
|
|
raw: bytes,
|
|
|
|
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
_validate_office_archive(raw, "xlsx")
|
|
|
|
|
|
merged_by_sheet, normalized_targets = _xlsx_sheet_merge_ranges(raw)
|
|
|
|
|
|
workbook_raw = (
|
|
|
|
|
|
_rewrite_xlsx_workbook_relationships(raw, normalized_targets)
|
|
|
|
|
|
if normalized_targets
|
|
|
|
|
|
else raw
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
workbook = load_workbook(
|
|
|
|
|
|
io.BytesIO(workbook_raw),
|
|
|
|
|
|
read_only=True,
|
|
|
|
|
|
data_only=True,
|
|
|
|
|
|
keep_links=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"invalid XLSX file: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
records: list[dict[str, Any]] = []
|
2026-07-30 16:53:54 +08:00
|
|
|
|
locators: list[dict[str, Any]] = []
|
2026-07-24 11:27:51 +08:00
|
|
|
|
total_cells = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
if len(workbook.worksheets) > _MAX_WORKBOOK_SHEETS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX contains too many worksheets (limit {_MAX_WORKBOOK_SHEETS})"
|
|
|
|
|
|
)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for sheet_index, worksheet in enumerate(workbook.worksheets):
|
2026-07-24 11:27:51 +08:00
|
|
|
|
reset_dimensions = getattr(worksheet, "reset_dimensions", None)
|
|
|
|
|
|
if callable(reset_dimensions):
|
|
|
|
|
|
reset_dimensions()
|
|
|
|
|
|
merged_ranges = merged_by_sheet.get(worksheet.title, ())
|
|
|
|
|
|
sheet_rows = 0
|
|
|
|
|
|
scanned_rows = 0
|
|
|
|
|
|
row_iterator = enumerate(
|
|
|
|
|
|
worksheet.iter_rows(values_only=True),
|
|
|
|
|
|
start=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
buffered_rows: dict[int, Sequence[Any]] = {}
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def normalized_row_values(
|
|
|
|
|
|
row: Sequence[Any],
|
|
|
|
|
|
sheet_title: str = worksheet.title,
|
|
|
|
|
|
) -> list[Any]:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
values = list(row)
|
|
|
|
|
|
while values and values[-1] in {None, ""}:
|
|
|
|
|
|
values.pop()
|
|
|
|
|
|
if len(values) > _MAX_WORKBOOK_COLUMNS:
|
|
|
|
|
|
raise ValueError(
|
2026-07-30 16:53:54 +08:00
|
|
|
|
f"XLSX worksheet {sheet_title!r} exceeds "
|
2026-07-24 11:27:51 +08:00
|
|
|
|
f"{_MAX_WORKBOOK_COLUMNS} columns"
|
|
|
|
|
|
)
|
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
for row_number, row in row_iterator:
|
|
|
|
|
|
scanned_rows += 1
|
|
|
|
|
|
if scanned_rows > _MAX_WORKBOOK_SCANNED_ROWS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {worksheet.title!r} exceeds "
|
|
|
|
|
|
f"{_MAX_WORKBOOK_SCANNED_ROWS} scanned rows"
|
|
|
|
|
|
)
|
|
|
|
|
|
values = normalized_row_values(row)
|
|
|
|
|
|
if not values or all(value in {None, ""} for value in values):
|
|
|
|
|
|
continue
|
|
|
|
|
|
buffered_rows[row_number] = tuple(values)
|
|
|
|
|
|
if len(buffered_rows) >= _MAX_WORKBOOK_HEADER_SCAN_ROWS:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not buffered_rows:
|
|
|
|
|
|
continue
|
|
|
|
|
|
_, header_end_row, headers = _infer_xlsx_header_region(
|
|
|
|
|
|
worksheet.title,
|
|
|
|
|
|
buffered_rows,
|
|
|
|
|
|
merged_ranges,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def append_record(
|
|
|
|
|
|
row_number: int,
|
|
|
|
|
|
values: Sequence[Any],
|
|
|
|
|
|
record_headers: Sequence[str] = tuple(headers),
|
|
|
|
|
|
locator_sheet_index: int = sheet_index,
|
|
|
|
|
|
sheet_title: str = worksheet.title,
|
|
|
|
|
|
) -> None:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
nonlocal total_cells, sheet_rows
|
|
|
|
|
|
row_values = list(values)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if len(row_values) > len(record_headers):
|
2026-07-24 11:27:51 +08:00
|
|
|
|
raise ValueError(
|
2026-07-30 16:53:54 +08:00
|
|
|
|
f"XLSX worksheet {sheet_title!r} has a row wider than its header"
|
2026-07-24 11:27:51 +08:00
|
|
|
|
)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
row_values.extend([None] * (len(record_headers) - len(row_values)))
|
2026-07-24 11:27:51 +08:00
|
|
|
|
record = {
|
|
|
|
|
|
header: _normalize_spreadsheet_value(value)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for header, value in zip(record_headers, row_values, strict=True)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
if not any(value not in {"", None} for value in record.values()):
|
|
|
|
|
|
return
|
2026-07-30 16:53:54 +08:00
|
|
|
|
sheet_record_index = sheet_rows
|
2026-07-24 11:27:51 +08:00
|
|
|
|
sheet_rows += 1
|
2026-07-30 16:53:54 +08:00
|
|
|
|
total_cells += len(record_headers)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if sheet_rows > _MAX_WORKBOOK_ROWS:
|
|
|
|
|
|
raise ValueError(
|
2026-07-30 16:53:54 +08:00
|
|
|
|
f"XLSX worksheet {sheet_title!r} exceeds "
|
2026-07-24 11:27:51 +08:00
|
|
|
|
f"{_MAX_WORKBOOK_ROWS} data rows"
|
|
|
|
|
|
)
|
|
|
|
|
|
if total_cells > _MAX_WORKBOOK_CELLS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX workbook exceeds {_MAX_WORKBOOK_CELLS} populated cells"
|
|
|
|
|
|
)
|
|
|
|
|
|
records.append(record)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
locators.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "xlsx",
|
|
|
|
|
|
"record_index": len(records),
|
|
|
|
|
|
"sheet_index": locator_sheet_index,
|
|
|
|
|
|
"sheet_name": sheet_title,
|
|
|
|
|
|
"row_number": row_number,
|
|
|
|
|
|
"sheet_record_index": sheet_record_index,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
for row_number, values in buffered_rows.items():
|
|
|
|
|
|
if row_number > header_end_row:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
append_record(row_number, values)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for row_number, row in row_iterator:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
scanned_rows += 1
|
|
|
|
|
|
if scanned_rows > _MAX_WORKBOOK_SCANNED_ROWS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"XLSX worksheet {worksheet.title!r} exceeds "
|
|
|
|
|
|
f"{_MAX_WORKBOOK_SCANNED_ROWS} scanned rows"
|
|
|
|
|
|
)
|
|
|
|
|
|
values = normalized_row_values(row)
|
|
|
|
|
|
if not values or all(value in {None, ""} for value in values):
|
|
|
|
|
|
continue
|
2026-07-30 16:53:54 +08:00
|
|
|
|
append_record(row_number, values)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
workbook.close()
|
2026-07-30 16:53:54 +08:00
|
|
|
|
return records, locators
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def _normalize_value(value: Any) -> Any:
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return normalize_text(value)
|
|
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
|
|
return {normalize_text(str(key)): _normalize_value(item) for key, item in value.items()}
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [_normalize_value(item) for item in value]
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _record_from_value(value: Any, *, normalize: bool = True) -> dict[str, Any]:
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if isinstance(value, Mapping):
|
2026-07-30 16:53:54 +08:00
|
|
|
|
return dict(_normalize_value(value)) if normalize else dict(value)
|
|
|
|
|
|
return {"value": _normalize_value(value) if normalize else value}
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _json_pointer_segment(value: Any) -> str:
|
|
|
|
|
|
return str(value).replace("~", "~0").replace("/", "~1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _DuplicateJsonKeyError(ValueError):
|
|
|
|
|
|
"""严格 JSON 解析时发现同一对象内的重复键。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
|
|
|
|
result: dict[str, Any] = {}
|
|
|
|
|
|
for key, value in pairs:
|
|
|
|
|
|
if key in result:
|
|
|
|
|
|
raise _DuplicateJsonKeyError(f"duplicate JSON object key: {key!r}")
|
|
|
|
|
|
result[key] = value
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reject_json_constant(value: str) -> Any:
|
|
|
|
|
|
raise ValueError(f"non-finite JSON number is not allowed: {value}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _skip_json_whitespace(text: str, offset: int) -> int:
|
|
|
|
|
|
while offset < len(text) and text[offset] in " \t\r\n":
|
|
|
|
|
|
offset += 1
|
|
|
|
|
|
return offset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_json_nesting(text: str) -> None:
|
|
|
|
|
|
"""在构造 Python 对象前限制容器深度,避免依赖解释器递归阈值。"""
|
|
|
|
|
|
|
|
|
|
|
|
depth = 0
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
for char in text:
|
|
|
|
|
|
if in_string:
|
|
|
|
|
|
if escaped:
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
elif char == "\\":
|
|
|
|
|
|
escaped = True
|
|
|
|
|
|
elif char == '"':
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
continue
|
|
|
|
|
|
if char == '"':
|
|
|
|
|
|
in_string = True
|
|
|
|
|
|
elif char in "[{":
|
|
|
|
|
|
depth += 1
|
|
|
|
|
|
if depth > _MAX_JSON_DEPTH:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"JSON nesting exceeds the supported depth of {_MAX_JSON_DEPTH}"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif char in "]}":
|
|
|
|
|
|
depth = max(0, depth - 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _strict_json_loads(text: str) -> tuple[Any, int, int]:
|
|
|
|
|
|
"""严格解析单个 JSON 值并返回其左闭右开源码区间。"""
|
|
|
|
|
|
|
|
|
|
|
|
start = _skip_json_whitespace(text, 0)
|
|
|
|
|
|
if start >= len(text):
|
|
|
|
|
|
raise ValueError("JSON content is empty")
|
|
|
|
|
|
_validate_json_nesting(text)
|
|
|
|
|
|
decoder = json.JSONDecoder(
|
|
|
|
|
|
object_pairs_hook=_reject_duplicate_json_keys,
|
|
|
|
|
|
parse_float=Decimal,
|
|
|
|
|
|
parse_int=int,
|
|
|
|
|
|
parse_constant=_reject_json_constant,
|
|
|
|
|
|
strict=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload, end = decoder.raw_decode(text, start)
|
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
except RecursionError as exc:
|
|
|
|
|
|
raise ValueError("JSON nesting exceeds the supported depth") from exc
|
|
|
|
|
|
except _DuplicateJsonKeyError as exc:
|
|
|
|
|
|
raise ValueError(str(exc)) from exc
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
# parse_int/parse_float/parse_constant 的异常也必须稳定映射为客户端错误。
|
|
|
|
|
|
raise ValueError(f"invalid JSON number: {exc}") from exc
|
|
|
|
|
|
trailing = _skip_json_whitespace(text, end)
|
|
|
|
|
|
if trailing != len(text):
|
|
|
|
|
|
line = text.count("\n", 0, trailing) + 1
|
|
|
|
|
|
line_start = text.rfind("\n", 0, trailing) + 1
|
|
|
|
|
|
column = trailing - line_start + 1
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"invalid JSON at line {line}, column {column}: extra data"
|
|
|
|
|
|
)
|
|
|
|
|
|
return payload, start, end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_value_end(text: str, start: int) -> int:
|
|
|
|
|
|
"""在已验证 JSON 中定位一个值的结束偏移,不对数值做二次解析。"""
|
|
|
|
|
|
|
|
|
|
|
|
if start >= len(text):
|
|
|
|
|
|
raise ValueError("invalid JSON source span")
|
|
|
|
|
|
first = text[start]
|
|
|
|
|
|
if first == '"':
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
for offset in range(start + 1, len(text)):
|
|
|
|
|
|
char = text[offset]
|
|
|
|
|
|
if escaped:
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
elif char == "\\":
|
|
|
|
|
|
escaped = True
|
|
|
|
|
|
elif char == '"':
|
|
|
|
|
|
return offset + 1
|
|
|
|
|
|
raise ValueError("invalid JSON source span")
|
|
|
|
|
|
if first in "[{":
|
|
|
|
|
|
stack = [first]
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
for offset in range(start + 1, len(text)):
|
|
|
|
|
|
char = text[offset]
|
|
|
|
|
|
if in_string:
|
|
|
|
|
|
if escaped:
|
|
|
|
|
|
escaped = False
|
|
|
|
|
|
elif char == "\\":
|
|
|
|
|
|
escaped = True
|
|
|
|
|
|
elif char == '"':
|
|
|
|
|
|
in_string = False
|
|
|
|
|
|
continue
|
|
|
|
|
|
if char == '"':
|
|
|
|
|
|
in_string = True
|
|
|
|
|
|
elif char in "[{":
|
|
|
|
|
|
stack.append(char)
|
|
|
|
|
|
elif char in "]}":
|
|
|
|
|
|
expected = "[" if char == "]" else "{"
|
|
|
|
|
|
if not stack or stack[-1] != expected:
|
|
|
|
|
|
raise ValueError("invalid JSON source span")
|
|
|
|
|
|
stack.pop()
|
|
|
|
|
|
if not stack:
|
|
|
|
|
|
return offset + 1
|
|
|
|
|
|
raise ValueError("invalid JSON source span")
|
|
|
|
|
|
end = start
|
|
|
|
|
|
while end < len(text) and text[end] not in " \t\r\n,]}":
|
|
|
|
|
|
end += 1
|
|
|
|
|
|
if end == start:
|
|
|
|
|
|
raise ValueError("invalid JSON source span")
|
|
|
|
|
|
return end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_object_value_spans(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
start: int,
|
|
|
|
|
|
end: int,
|
|
|
|
|
|
) -> dict[str, tuple[int, int]]:
|
|
|
|
|
|
"""返回已验证 JSON 对象直接子字段的值区间。"""
|
|
|
|
|
|
|
|
|
|
|
|
if text[start] != "{" or text[end - 1] != "}":
|
|
|
|
|
|
raise ValueError("JSON source value is not an object")
|
|
|
|
|
|
result: dict[str, tuple[int, int]] = {}
|
|
|
|
|
|
offset = _skip_json_whitespace(text, start + 1)
|
|
|
|
|
|
key_decoder = json.JSONDecoder()
|
|
|
|
|
|
while offset < end - 1:
|
|
|
|
|
|
key, key_end = key_decoder.raw_decode(text, offset)
|
|
|
|
|
|
if not isinstance(key, str):
|
|
|
|
|
|
raise ValueError("invalid JSON object key")
|
|
|
|
|
|
offset = _skip_json_whitespace(text, key_end)
|
|
|
|
|
|
if offset >= end or text[offset] != ":":
|
|
|
|
|
|
raise ValueError("invalid JSON object member")
|
|
|
|
|
|
value_start = _skip_json_whitespace(text, offset + 1)
|
|
|
|
|
|
value_end = _json_value_end(text, value_start)
|
|
|
|
|
|
result[key] = (value_start, value_end)
|
|
|
|
|
|
offset = _skip_json_whitespace(text, value_end)
|
|
|
|
|
|
if offset >= end - 1:
|
|
|
|
|
|
break
|
|
|
|
|
|
if text[offset] != ",":
|
|
|
|
|
|
raise ValueError("invalid JSON object member")
|
|
|
|
|
|
offset = _skip_json_whitespace(text, offset + 1)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_array_item_spans(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
start: int,
|
|
|
|
|
|
end: int,
|
|
|
|
|
|
) -> list[tuple[int, int]]:
|
|
|
|
|
|
"""返回已验证 JSON 数组中每个直接元素的源码区间。"""
|
|
|
|
|
|
|
|
|
|
|
|
if text[start] != "[" or text[end - 1] != "]":
|
|
|
|
|
|
raise ValueError("JSON source value is not an array")
|
|
|
|
|
|
result: list[tuple[int, int]] = []
|
|
|
|
|
|
offset = _skip_json_whitespace(text, start + 1)
|
|
|
|
|
|
while offset < end - 1:
|
|
|
|
|
|
item_end = _json_value_end(text, offset)
|
|
|
|
|
|
result.append((offset, item_end))
|
|
|
|
|
|
offset = _skip_json_whitespace(text, item_end)
|
|
|
|
|
|
if offset >= end - 1:
|
|
|
|
|
|
break
|
|
|
|
|
|
if text[offset] != ",":
|
|
|
|
|
|
raise ValueError("invalid JSON array item")
|
|
|
|
|
|
offset = _skip_json_whitespace(text, offset + 1)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_span_at_path(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
root_span: tuple[int, int],
|
|
|
|
|
|
path: Sequence[str],
|
|
|
|
|
|
) -> tuple[int, int]:
|
|
|
|
|
|
span = root_span
|
|
|
|
|
|
for key in path:
|
|
|
|
|
|
try:
|
|
|
|
|
|
span = _json_object_value_spans(text, *span)[key]
|
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
|
raise ValueError(f"JSON source path cannot be located: {key}") from exc
|
|
|
|
|
|
return span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pure_json_record_wrapper(
|
|
|
|
|
|
payload: Any,
|
|
|
|
|
|
) -> tuple[list[Any], tuple[str, ...]] | None:
|
|
|
|
|
|
"""识别不会与业务字段冲突的纯记录包装对象。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(payload, Mapping):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def direct_wrapper(
|
|
|
|
|
|
value: Mapping[str, Any],
|
|
|
|
|
|
metadata_keys: frozenset[str] | set[str] = _JSON_WRAPPER_METADATA_KEYS,
|
|
|
|
|
|
) -> tuple[list[Any], tuple[str, ...]] | None:
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
key
|
|
|
|
|
|
for key in _JSON_RECORD_ARRAY_KEYS
|
|
|
|
|
|
if isinstance(value.get(key), list)
|
|
|
|
|
|
]
|
|
|
|
|
|
if len(candidates) != 1:
|
|
|
|
|
|
return None
|
|
|
|
|
|
record_key = candidates[0]
|
|
|
|
|
|
records = value[record_key]
|
|
|
|
|
|
if any(not isinstance(record, Mapping) for record in records):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if any(
|
|
|
|
|
|
key != record_key and key not in metadata_keys
|
|
|
|
|
|
for key in value
|
|
|
|
|
|
):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return records, (record_key,)
|
|
|
|
|
|
|
|
|
|
|
|
direct = direct_wrapper(payload)
|
|
|
|
|
|
if direct is not None:
|
|
|
|
|
|
return direct
|
|
|
|
|
|
|
|
|
|
|
|
envelope_keys = [
|
|
|
|
|
|
key
|
|
|
|
|
|
for key in _JSON_ENVELOPE_KEYS
|
|
|
|
|
|
if isinstance(payload.get(key), Mapping)
|
|
|
|
|
|
]
|
|
|
|
|
|
if len(envelope_keys) != 1:
|
|
|
|
|
|
return None
|
|
|
|
|
|
envelope_key = envelope_keys[0]
|
|
|
|
|
|
if any(
|
|
|
|
|
|
key != envelope_key and key not in _JSON_RESPONSE_METADATA_KEYS
|
|
|
|
|
|
for key in payload
|
|
|
|
|
|
):
|
|
|
|
|
|
return None
|
|
|
|
|
|
nested = direct_wrapper(
|
|
|
|
|
|
payload[envelope_key],
|
|
|
|
|
|
_JSON_RESPONSE_METADATA_KEYS,
|
|
|
|
|
|
)
|
|
|
|
|
|
if nested is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
records, nested_path = nested
|
|
|
|
|
|
return records, (envelope_key, *nested_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_record_locator(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
record_index: int,
|
|
|
|
|
|
json_pointer: str,
|
|
|
|
|
|
span: tuple[int, int],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
source_start, source_end = span
|
|
|
|
|
|
start_line = text.count("\n", 0, source_start) + 1
|
|
|
|
|
|
last_character = max(source_start, source_end - 1)
|
|
|
|
|
|
end_line = text.count("\n", 0, last_character) + 1
|
|
|
|
|
|
return {
|
|
|
|
|
|
"kind": "json",
|
|
|
|
|
|
"record_index": record_index,
|
|
|
|
|
|
"json_pointer": json_pointer,
|
|
|
|
|
|
"source_start": source_start,
|
|
|
|
|
|
"source_end": source_end,
|
|
|
|
|
|
"start_line": start_line,
|
|
|
|
|
|
"end_line": end_line,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _source_line_offsets(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
start_line: int,
|
|
|
|
|
|
end_line: int,
|
|
|
|
|
|
) -> tuple[int, int]:
|
|
|
|
|
|
"""把 1-based 物理行范围转换为左闭右开的字符范围。"""
|
|
|
|
|
|
|
|
|
|
|
|
line_starts = [0]
|
|
|
|
|
|
line_starts.extend(match.end() for match in re.finditer("\n", text))
|
|
|
|
|
|
if start_line < 1 or end_line < start_line or end_line > len(line_starts):
|
|
|
|
|
|
raise ValueError("source line range is outside normalized text")
|
|
|
|
|
|
source_start = line_starts[start_line - 1]
|
|
|
|
|
|
source_end = (
|
|
|
|
|
|
line_starts[end_line] - 1
|
|
|
|
|
|
if end_line < len(line_starts)
|
|
|
|
|
|
else len(text)
|
|
|
|
|
|
)
|
|
|
|
|
|
return source_start, source_end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_structured_records_with_locators(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
file_format: str,
|
|
|
|
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
|
|
|
|
"""从 JSON、JSONL 或 CSV 中提取记录。
|
|
|
|
|
|
|
|
|
|
|
|
JSON 根数组始终表示多条记录;对象仅在满足纯包装契约时展开,其他
|
|
|
|
|
|
对象均视为一条业务记录。JSON 字段和值在解析阶段保持原样,只有用户
|
|
|
|
|
|
明确选择 ``normalize_format`` 后才会规范化。
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
normalized_format = _normalize_format(file_format)
|
|
|
|
|
|
if normalized_format not in {"json", "jsonl", "csv"}:
|
|
|
|
|
|
raise ValueError("structured record extraction only supports JSON, JSONL and CSV")
|
|
|
|
|
|
|
|
|
|
|
|
if normalized_format == "json":
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if _skip_json_whitespace(text, 0) == len(text):
|
|
|
|
|
|
return [], []
|
|
|
|
|
|
payload, root_start, root_end = _strict_json_loads(text)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
values: Sequence[Any]
|
2026-07-30 16:53:54 +08:00
|
|
|
|
pointer_path: tuple[str, ...] = ()
|
|
|
|
|
|
record_spans: list[tuple[int, int]]
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if isinstance(payload, list):
|
|
|
|
|
|
values = payload
|
2026-07-30 16:53:54 +08:00
|
|
|
|
record_spans = _json_array_item_spans(text, root_start, root_end)
|
|
|
|
|
|
else:
|
|
|
|
|
|
wrapper = _pure_json_record_wrapper(payload)
|
|
|
|
|
|
if wrapper is None:
|
|
|
|
|
|
values = [payload]
|
|
|
|
|
|
record_spans = [(root_start, root_end)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
values, pointer_path = wrapper
|
|
|
|
|
|
array_span = _json_span_at_path(
|
|
|
|
|
|
text,
|
|
|
|
|
|
(root_start, root_end),
|
|
|
|
|
|
pointer_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
record_spans = _json_array_item_spans(text, *array_span)
|
|
|
|
|
|
if len(record_spans) != len(values):
|
|
|
|
|
|
raise ValueError("JSON record source spans do not match parsed records")
|
|
|
|
|
|
records = [_record_from_value(value, normalize=False) for value in values]
|
|
|
|
|
|
pointer_prefix = "".join(
|
|
|
|
|
|
f"/{_json_pointer_segment(segment)}" for segment in pointer_path
|
|
|
|
|
|
)
|
|
|
|
|
|
locators = [
|
|
|
|
|
|
_json_record_locator(
|
|
|
|
|
|
text,
|
|
|
|
|
|
record_index=index + 1,
|
|
|
|
|
|
json_pointer=(
|
|
|
|
|
|
f"{pointer_prefix}/{index}"
|
|
|
|
|
|
if pointer_path or isinstance(payload, list)
|
|
|
|
|
|
else ""
|
2026-07-23 15:10:13 +08:00
|
|
|
|
),
|
2026-07-30 16:53:54 +08:00
|
|
|
|
span=record_spans[index],
|
2026-07-23 15:10:13 +08:00
|
|
|
|
)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for index in range(len(records))
|
|
|
|
|
|
]
|
|
|
|
|
|
return records, locators
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
if normalized_format == "jsonl":
|
|
|
|
|
|
records: list[dict[str, Any]] = []
|
2026-07-30 16:53:54 +08:00
|
|
|
|
locators: list[dict[str, Any]] = []
|
|
|
|
|
|
source_offset = 0
|
|
|
|
|
|
for line_number, line in enumerate(text.split("\n"), start=1):
|
|
|
|
|
|
line_content_end = len(line)
|
|
|
|
|
|
if _skip_json_whitespace(line, 0) == line_content_end:
|
|
|
|
|
|
source_offset += len(line) + 1
|
2026-07-23 15:10:13 +08:00
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
value, value_start, value_end = _strict_json_loads(line)
|
|
|
|
|
|
except ValueError as exc:
|
2026-07-23 15:10:13 +08:00
|
|
|
|
raise ValueError(
|
2026-07-30 16:53:54 +08:00
|
|
|
|
f"invalid JSONL at line {line_number}: {exc}"
|
2026-07-23 15:10:13 +08:00
|
|
|
|
) from exc
|
2026-07-30 16:53:54 +08:00
|
|
|
|
records.append(_record_from_value(value, normalize=False))
|
|
|
|
|
|
locators.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "jsonl",
|
|
|
|
|
|
"record_index": len(records),
|
|
|
|
|
|
"start_line": line_number,
|
|
|
|
|
|
"end_line": line_number,
|
|
|
|
|
|
"source_start": source_offset + value_start,
|
|
|
|
|
|
"source_end": source_offset + value_end,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
source_offset += len(line) + 1
|
|
|
|
|
|
return records, locators
|
|
|
|
|
|
|
|
|
|
|
|
normalized_text = normalize_text(text)
|
|
|
|
|
|
if not normalized_text:
|
|
|
|
|
|
return [], []
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
dialect = csv.Sniffer().sniff(normalized_text[:8192], delimiters=",\t;")
|
|
|
|
|
|
except csv.Error:
|
|
|
|
|
|
dialect = csv.excel
|
|
|
|
|
|
reader = csv.DictReader(io.StringIO(normalized_text), dialect=dialect)
|
|
|
|
|
|
if not reader.fieldnames:
|
|
|
|
|
|
raise ValueError("CSV header is required")
|
|
|
|
|
|
headers = [normalize_text(header or "") for header in reader.fieldnames]
|
|
|
|
|
|
if any(not header for header in headers):
|
|
|
|
|
|
raise ValueError("CSV header cannot be empty")
|
|
|
|
|
|
if len(set(headers)) != len(headers):
|
|
|
|
|
|
raise ValueError("CSV headers must be unique")
|
|
|
|
|
|
reader.fieldnames = headers
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
records: list[dict[str, Any]] = []
|
|
|
|
|
|
locators = []
|
|
|
|
|
|
source_lines = normalized_text.splitlines()
|
|
|
|
|
|
previous_end_line = reader.line_num
|
2026-07-23 15:10:13 +08:00
|
|
|
|
for row in reader:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
end_line = reader.line_num
|
|
|
|
|
|
start_line = previous_end_line + 1
|
|
|
|
|
|
previous_end_line = end_line
|
|
|
|
|
|
while start_line < end_line and not source_lines[start_line - 1].strip():
|
|
|
|
|
|
start_line += 1
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if None in row:
|
|
|
|
|
|
raise ValueError("CSV row has more fields than the header")
|
|
|
|
|
|
normalized_row = {
|
|
|
|
|
|
key: normalize_text(value or "")
|
|
|
|
|
|
for key, value in row.items()
|
|
|
|
|
|
}
|
|
|
|
|
|
if any(value for value in normalized_row.values()):
|
|
|
|
|
|
records.append(normalized_row)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
source_start, source_end = _source_line_offsets(
|
|
|
|
|
|
normalized_text,
|
|
|
|
|
|
start_line,
|
|
|
|
|
|
end_line,
|
|
|
|
|
|
)
|
|
|
|
|
|
locators.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "csv",
|
|
|
|
|
|
"record_index": len(records),
|
|
|
|
|
|
"start_line": start_line,
|
|
|
|
|
|
"end_line": end_line,
|
|
|
|
|
|
"source_start": source_start,
|
|
|
|
|
|
"source_end": source_end,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return records, locators
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_structured_records(text: str, file_format: str) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""从 JSON、JSONL 或 CSV 中提取规范化记录。"""
|
|
|
|
|
|
|
|
|
|
|
|
records, _ = _extract_structured_records_with_locators(text, file_format)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_text_content(
|
|
|
|
|
|
raw: bytes | bytearray | memoryview | str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
filename: str | None = None,
|
|
|
|
|
|
file_format: str | None = None,
|
|
|
|
|
|
) -> ParsedText:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"""安全解析 UTF-8 文本、文本型 PDF 和现代 Office 文件。"""
|
|
|
|
|
|
|
|
|
|
|
|
detected_format = detect_text_format(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
text="",
|
|
|
|
|
|
file_format=file_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
if detected_format in _OFFICE_OPEN_XML_FORMATS or detected_format == "pdf":
|
|
|
|
|
|
binary = _binary_bytes(raw, detected_format)
|
|
|
|
|
|
if detected_format == "pdf":
|
|
|
|
|
|
text = _extract_pdf_text(binary)
|
|
|
|
|
|
return ParsedText(format=detected_format, text=text, records=())
|
|
|
|
|
|
if detected_format == "docx":
|
|
|
|
|
|
text = _extract_docx_text(binary)
|
|
|
|
|
|
return ParsedText(format=detected_format, text=text, records=())
|
|
|
|
|
|
if detected_format == "pptx":
|
|
|
|
|
|
text = _extract_pptx_text(binary)
|
|
|
|
|
|
return ParsedText(format=detected_format, text=text, records=())
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
records, record_locators = _extract_xlsx_records(binary)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
text = "\n".join(
|
|
|
|
|
|
json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
for record in records
|
|
|
|
|
|
)
|
|
|
|
|
|
return ParsedText(
|
|
|
|
|
|
format=detected_format,
|
|
|
|
|
|
text=normalize_text(text),
|
|
|
|
|
|
records=tuple(records),
|
2026-07-30 16:53:54 +08:00
|
|
|
|
record_locators=tuple(record_locators),
|
2026-07-24 11:27:51 +08:00
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
decoded_text = decode_utf8(raw)
|
|
|
|
|
|
detected_format = detect_text_format(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
text=decoded_text,
|
|
|
|
|
|
file_format=file_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
# JSON/JSONL 是有损规范化的禁区:NFKC、控制字符删除或 trim 都可能改变字段值、
|
|
|
|
|
|
# 掩盖非法输入,甚至把原本合法的字符串变成语法错误。其他格式保持历史行为。
|
|
|
|
|
|
text = (
|
|
|
|
|
|
decoded_text
|
|
|
|
|
|
if detected_format in {"json", "jsonl"}
|
|
|
|
|
|
else normalize_text(decoded_text)
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
records: list[dict[str, Any]] = []
|
2026-07-30 16:53:54 +08:00
|
|
|
|
record_locators: list[dict[str, Any]] = []
|
2026-07-23 15:10:13 +08:00
|
|
|
|
if detected_format in {"json", "jsonl", "csv"}:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
records, record_locators = _extract_structured_records_with_locators(
|
|
|
|
|
|
text,
|
|
|
|
|
|
detected_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
return ParsedText(
|
|
|
|
|
|
format=detected_format,
|
|
|
|
|
|
text=text,
|
|
|
|
|
|
records=tuple(records),
|
|
|
|
|
|
record_locators=tuple(record_locators),
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def desensitize_pii(text: str) -> tuple[str, dict[str, int]]:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"""掩码邮箱、手机号、身份证号及有明确上下文的姓名。"""
|
2026-07-23 15:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
|
raise TypeError("text must be str")
|
|
|
|
|
|
counts: dict[str, int] = {"email": 0, "phone": 0, "id_card": 0}
|
|
|
|
|
|
|
|
|
|
|
|
def replace(pattern: re.Pattern[str], replacement: str, kind: str, value: str) -> str:
|
|
|
|
|
|
def replacer(_: re.Match[str]) -> str:
|
|
|
|
|
|
counts[kind] += 1
|
|
|
|
|
|
return replacement
|
|
|
|
|
|
|
|
|
|
|
|
return pattern.sub(replacer, value)
|
|
|
|
|
|
|
|
|
|
|
|
masked = replace(_EMAIL_PATTERN, "[EMAIL]", "email", text)
|
|
|
|
|
|
masked = replace(_ID_CARD_PATTERN, "[ID_CARD]", "id_card", masked)
|
|
|
|
|
|
masked = replace(_PHONE_PATTERN, "[PHONE]", "phone", masked)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
def replace_context_name(match: re.Match[str]) -> str:
|
|
|
|
|
|
counts["name"] = counts.get("name", 0) + 1
|
|
|
|
|
|
return f"{match.group('label')}{match.group('separator')}[NAME]"
|
|
|
|
|
|
|
|
|
|
|
|
masked = _CHINESE_NAME_CONTEXT_PATTERN.sub(replace_context_name, masked)
|
|
|
|
|
|
masked = _ENGLISH_NAME_CONTEXT_PATTERN.sub(replace_context_name, masked)
|
|
|
|
|
|
counts["total"] = sum(counts.values())
|
|
|
|
|
|
return masked, counts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_empty_value(value: Any) -> bool:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return not normalize_text(value)
|
|
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
|
|
return not value or all(_is_empty_value(item) for item in value.values())
|
|
|
|
|
|
if isinstance(value, (list, tuple, set, frozenset)):
|
|
|
|
|
|
return not value or all(_is_empty_value(item) for item in value)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _canonical_value(value: Any) -> Any:
|
|
|
|
|
|
if value is None or isinstance(value, (bool, int)):
|
|
|
|
|
|
return value
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if isinstance(value, Decimal):
|
|
|
|
|
|
if not value.is_finite():
|
|
|
|
|
|
raise ValueError("non-finite JSON number is not allowed")
|
|
|
|
|
|
return value
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if isinstance(value, float):
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if not math.isfinite(value):
|
|
|
|
|
|
raise ValueError("non-finite JSON number is not allowed")
|
|
|
|
|
|
return value
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return normalize_text(value)
|
|
|
|
|
|
if isinstance(value, (datetime, date, time)):
|
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
|
|
normalized: dict[str, Any] = {}
|
|
|
|
|
|
for key, item in sorted(value.items(), key=lambda pair: str(pair[0])):
|
|
|
|
|
|
normalized_key = normalize_text(str(key))
|
|
|
|
|
|
if not normalized_key:
|
|
|
|
|
|
raise ValueError("structured record contains an empty field name")
|
|
|
|
|
|
if normalized_key in normalized:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"structured record fields collide after normalization: {normalized_key}"
|
|
|
|
|
|
)
|
|
|
|
|
|
normalized[normalized_key] = _canonical_value(item)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
if isinstance(value, (list, tuple)):
|
|
|
|
|
|
return [_canonical_value(item) for item in value]
|
|
|
|
|
|
if isinstance(value, (set, frozenset)):
|
|
|
|
|
|
items = [_canonical_value(item) for item in value]
|
|
|
|
|
|
return sorted(
|
|
|
|
|
|
items,
|
2026-07-30 16:53:54 +08:00
|
|
|
|
key=lambda item: structured_json_dumps(item, sort_keys=True),
|
2026-07-24 11:27:51 +08:00
|
|
|
|
)
|
|
|
|
|
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
|
|
|
|
return bytes(value).hex()
|
|
|
|
|
|
return normalize_text(str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def structured_json_dumps(value: Any, *, sort_keys: bool = False) -> str:
|
|
|
|
|
|
"""序列化紧凑 JSON,并把 ``Decimal`` 保持为原值对应的 JSON 数字。
|
|
|
|
|
|
|
|
|
|
|
|
标准库会要求先把 ``Decimal`` 转成 float 或字符串;前者可能静默舍入,
|
|
|
|
|
|
后者会改变 JSON 类型。这里直接输出有限 Decimal 的十进制表示。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def serialize(item: Any) -> str:
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return "null"
|
|
|
|
|
|
if item is True:
|
|
|
|
|
|
return "true"
|
|
|
|
|
|
if item is False:
|
|
|
|
|
|
return "false"
|
|
|
|
|
|
if isinstance(item, int):
|
|
|
|
|
|
return str(item)
|
|
|
|
|
|
if isinstance(item, Decimal):
|
|
|
|
|
|
if not item.is_finite():
|
|
|
|
|
|
raise ValueError("non-finite JSON number is not allowed")
|
|
|
|
|
|
return str(item)
|
|
|
|
|
|
if isinstance(item, float):
|
|
|
|
|
|
if not math.isfinite(item):
|
|
|
|
|
|
raise ValueError("non-finite JSON number is not allowed")
|
|
|
|
|
|
return json.dumps(item, allow_nan=False)
|
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
|
return json.dumps(item, ensure_ascii=False)
|
|
|
|
|
|
if isinstance(item, Mapping):
|
|
|
|
|
|
pairs: list[tuple[str, Any]] = []
|
|
|
|
|
|
seen_keys: set[str] = set()
|
|
|
|
|
|
for key, child in item.items():
|
|
|
|
|
|
if not isinstance(key, str):
|
|
|
|
|
|
raise TypeError("JSON object keys must be strings")
|
|
|
|
|
|
if key in seen_keys:
|
|
|
|
|
|
raise ValueError(f"duplicate JSON object key: {key!r}")
|
|
|
|
|
|
seen_keys.add(key)
|
|
|
|
|
|
pairs.append((key, child))
|
|
|
|
|
|
if sort_keys:
|
|
|
|
|
|
pairs.sort(key=lambda pair: pair[0])
|
|
|
|
|
|
return "{" + ",".join(
|
|
|
|
|
|
f"{json.dumps(key, ensure_ascii=False)}:{serialize(child)}"
|
|
|
|
|
|
for key, child in pairs
|
|
|
|
|
|
) + "}"
|
|
|
|
|
|
if isinstance(item, (list, tuple)):
|
|
|
|
|
|
return "[" + ",".join(serialize(child) for child in item) + "]"
|
|
|
|
|
|
raise TypeError(f"value of type {type(item).__name__} is not JSON serializable")
|
|
|
|
|
|
|
|
|
|
|
|
return serialize(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def canonical_record_json(record: Mapping[str, Any]) -> str:
|
|
|
|
|
|
"""生成与字段顺序无关、可用于比较和落库的 canonical JSON。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(record, Mapping):
|
|
|
|
|
|
raise TypeError("record must be a mapping")
|
2026-07-30 16:53:54 +08:00
|
|
|
|
return structured_json_dumps(_canonical_value(record), sort_keys=True)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_field_name(value: Any, style: str) -> str:
|
|
|
|
|
|
name = normalize_text(str(value))
|
|
|
|
|
|
if style == "preserve":
|
|
|
|
|
|
return name
|
|
|
|
|
|
if style == "lower":
|
|
|
|
|
|
return name.lower()
|
|
|
|
|
|
if style != "snake_case":
|
|
|
|
|
|
raise ValueError("field_name_style must be snake_case, lower or preserve")
|
|
|
|
|
|
name = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
|
|
|
|
|
|
name = re.sub(r"[^\w\u3400-\u4dbf\u4e00-\u9fff]+", "_", name, flags=re.UNICODE)
|
|
|
|
|
|
return re.sub(r"_+", "_", name).strip("_").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_structured_record(
|
|
|
|
|
|
record: Mapping[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
field_name_style: str = "snake_case",
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""规范字段名、Unicode/空白、容器类型和不可 JSON 化的标量。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(record, Mapping):
|
|
|
|
|
|
raise TypeError("record must be a mapping")
|
|
|
|
|
|
normalized: dict[str, Any] = {}
|
|
|
|
|
|
for key, value in record.items():
|
|
|
|
|
|
normalized_key = _normalize_field_name(key, field_name_style)
|
|
|
|
|
|
if not normalized_key:
|
|
|
|
|
|
raise ValueError("structured record contains an empty field name")
|
|
|
|
|
|
if normalized_key in normalized:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"structured record fields collide after normalization: {normalized_key}"
|
|
|
|
|
|
)
|
|
|
|
|
|
normalized[normalized_key] = _canonical_value(value)
|
|
|
|
|
|
return dict(sorted(normalized.items()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _embedded_structure(value: Any) -> Any:
|
|
|
|
|
|
if not isinstance(value, str):
|
|
|
|
|
|
return value
|
|
|
|
|
|
candidate = value.strip()
|
|
|
|
|
|
if not candidate or candidate[0] not in "[{":
|
|
|
|
|
|
return value
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = json.loads(candidate)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
return value
|
|
|
|
|
|
return parsed if isinstance(parsed, (Mapping, list)) else value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def flatten_structured_record(
|
|
|
|
|
|
record: Mapping[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
separator: str = ".",
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""把嵌套对象展平;数组保留为 canonical JSON 兼容值。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(record, Mapping):
|
|
|
|
|
|
raise TypeError("record must be a mapping")
|
|
|
|
|
|
if not separator:
|
|
|
|
|
|
raise ValueError("separator cannot be empty")
|
|
|
|
|
|
flattened: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def visit(value: Any, path: tuple[str, ...], depth: int) -> None:
|
|
|
|
|
|
if depth > _MAX_STRUCTURED_DEPTH:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"structured record nesting exceeds {_MAX_STRUCTURED_DEPTH} levels"
|
|
|
|
|
|
)
|
|
|
|
|
|
value = _embedded_structure(value)
|
|
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
|
|
if not value and path:
|
|
|
|
|
|
key = separator.join(path)
|
|
|
|
|
|
flattened[key] = {}
|
|
|
|
|
|
return
|
|
|
|
|
|
for child_key, child_value in value.items():
|
|
|
|
|
|
normalized_key = normalize_text(str(child_key))
|
|
|
|
|
|
if not normalized_key:
|
|
|
|
|
|
raise ValueError("structured record contains an empty field name")
|
|
|
|
|
|
visit(child_value, (*path, normalized_key), depth + 1)
|
|
|
|
|
|
return
|
|
|
|
|
|
key = separator.join(path)
|
|
|
|
|
|
if key in flattened:
|
|
|
|
|
|
raise ValueError(f"structured record fields collide while flattening: {key}")
|
|
|
|
|
|
flattened[key] = _canonical_value(value)
|
|
|
|
|
|
if len(flattened) > _MAX_STRUCTURED_FIELDS:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"structured record exceeds {_MAX_STRUCTURED_FIELDS} flattened fields"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for field, value in record.items():
|
|
|
|
|
|
field_name = normalize_text(str(field))
|
|
|
|
|
|
if not field_name:
|
|
|
|
|
|
raise ValueError("structured record contains an empty field name")
|
|
|
|
|
|
visit(value, (field_name,), 1)
|
|
|
|
|
|
return flattened
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _clean_invalid_structured_entries(
|
|
|
|
|
|
entries: Sequence[ProcessedStructuredRecord],
|
|
|
|
|
|
) -> list[ProcessedStructuredRecord]:
|
|
|
|
|
|
if not entries:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return []
|
|
|
|
|
|
fields: list[str] = []
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for entry in entries:
|
|
|
|
|
|
record = entry.record
|
2026-07-24 11:27:51 +08:00
|
|
|
|
for field in record:
|
|
|
|
|
|
if field not in fields:
|
|
|
|
|
|
fields.append(field)
|
|
|
|
|
|
active_fields = [
|
|
|
|
|
|
field
|
|
|
|
|
|
for field in fields
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if any(not _is_empty_value(entry.record.get(field)) for entry in entries)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
]
|
|
|
|
|
|
if not active_fields:
|
|
|
|
|
|
return []
|
2026-07-30 16:53:54 +08:00
|
|
|
|
cleaned: list[ProcessedStructuredRecord] = []
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
|
record = entry.record
|
2026-07-24 11:27:51 +08:00
|
|
|
|
values = {field: deepcopy(record.get(field)) for field in active_fields}
|
2026-07-30 16:53:54 +08:00
|
|
|
|
# 清洗只依据整行是否为空。外键、父级 ID 等字段天然允许为空,不能
|
|
|
|
|
|
# 因为字段名以 *_id 结尾就把它们全部提升为联合必填项。
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if all(_is_empty_value(value) for value in values.values()):
|
|
|
|
|
|
continue
|
2026-07-30 16:53:54 +08:00
|
|
|
|
cleaned.append(ProcessedStructuredRecord(entry.source_index, values))
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _clean_invalid_structured_records(
|
|
|
|
|
|
records: Sequence[Mapping[str, Any]],
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
entries = [
|
|
|
|
|
|
ProcessedStructuredRecord(index, deepcopy(dict(record)))
|
|
|
|
|
|
for index, record in enumerate(records)
|
|
|
|
|
|
]
|
|
|
|
|
|
return [entry.record for entry in _clean_invalid_structured_entries(entries)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def _percentile(values: Sequence[float], fraction: float) -> float:
|
|
|
|
|
|
if not values:
|
|
|
|
|
|
raise ValueError("cannot calculate a percentile of an empty sequence")
|
|
|
|
|
|
ordered = sorted(values)
|
|
|
|
|
|
position = (len(ordered) - 1) * fraction
|
|
|
|
|
|
lower = math.floor(position)
|
|
|
|
|
|
upper = math.ceil(position)
|
|
|
|
|
|
if lower == upper:
|
|
|
|
|
|
return ordered[lower]
|
|
|
|
|
|
weight = position - lower
|
|
|
|
|
|
return ordered[lower] * (1 - weight) + ordered[upper] * weight
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def content_quality_flags(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
min_chars: int = 20,
|
|
|
|
|
|
min_tokens: int = 5,
|
|
|
|
|
|
max_chars: int = _MAX_ANOMALY_TEXT_CHARS,
|
|
|
|
|
|
) -> tuple[str, ...]:
|
|
|
|
|
|
"""返回非结构化内容的确定性低质量原因。"""
|
|
|
|
|
|
|
|
|
|
|
|
if min_chars < 0 or min_tokens < 0 or max_chars <= 0:
|
|
|
|
|
|
raise ValueError("content quality limits must be non-negative")
|
|
|
|
|
|
normalized = normalize_text(text)
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
return ("empty_content",)
|
|
|
|
|
|
flags: list[str] = []
|
|
|
|
|
|
if len(normalized) < min_chars or estimate_token_count(normalized) < min_tokens:
|
|
|
|
|
|
flags.append("content_too_short")
|
|
|
|
|
|
if len(normalized) > max_chars:
|
|
|
|
|
|
flags.append("content_too_long")
|
|
|
|
|
|
if any(marker in normalized for marker in _MOJIBAKE_MARKERS):
|
|
|
|
|
|
flags.append("mojibake")
|
|
|
|
|
|
nonspace = [char for char in normalized if not char.isspace()]
|
|
|
|
|
|
if nonspace:
|
|
|
|
|
|
readable_ratio = sum(
|
|
|
|
|
|
char.isprintable()
|
|
|
|
|
|
and unicodedata.category(char) not in {"Co", "Cs", "Cn"}
|
|
|
|
|
|
for char in nonspace
|
|
|
|
|
|
) / len(nonspace)
|
|
|
|
|
|
if readable_ratio < 0.85:
|
|
|
|
|
|
flags.append("low_printable_ratio")
|
|
|
|
|
|
if len(nonspace) >= 100:
|
|
|
|
|
|
most_common = Counter(nonspace).most_common(1)[0][1]
|
|
|
|
|
|
if most_common / len(nonspace) > 0.9:
|
|
|
|
|
|
flags.append("repetitive_content")
|
|
|
|
|
|
return tuple(dict.fromkeys(flags))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_low_quality_content(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
min_chars: int = 20,
|
|
|
|
|
|
min_tokens: int = 5,
|
|
|
|
|
|
max_chars: int = _MAX_ANOMALY_TEXT_CHARS,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""判断内容是否命中任一低质量规则。"""
|
|
|
|
|
|
|
|
|
|
|
|
return bool(
|
|
|
|
|
|
content_quality_flags(
|
|
|
|
|
|
text,
|
|
|
|
|
|
min_chars=min_chars,
|
|
|
|
|
|
min_tokens=min_tokens,
|
|
|
|
|
|
max_chars=max_chars,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _filter_anomalous_structured_entries(
|
|
|
|
|
|
entries: Sequence[ProcessedStructuredRecord],
|
2026-07-24 11:27:51 +08:00
|
|
|
|
*,
|
|
|
|
|
|
iqr_multiplier: float = 1.5,
|
2026-07-30 16:53:54 +08:00
|
|
|
|
) -> list[ProcessedStructuredRecord]:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"""按字段级数值 IQR、乱码和极端文本长度过滤异常记录。"""
|
|
|
|
|
|
|
|
|
|
|
|
if iqr_multiplier <= 0:
|
|
|
|
|
|
raise ValueError("iqr_multiplier must be greater than 0")
|
|
|
|
|
|
numeric_values: dict[str, list[float]] = {}
|
|
|
|
|
|
text_lengths: dict[str, list[float]] = {}
|
2026-07-30 16:53:54 +08:00
|
|
|
|
for entry in entries:
|
|
|
|
|
|
record = entry.record
|
2026-07-24 11:27:51 +08:00
|
|
|
|
for field, value in record.items():
|
|
|
|
|
|
if (
|
|
|
|
|
|
isinstance(value, (int, float))
|
|
|
|
|
|
and not isinstance(value, bool)
|
|
|
|
|
|
and not _IDENTITY_FIELD_PATTERN.search(
|
|
|
|
|
|
_normalize_field_name(field, "snake_case")
|
|
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
number = float(value)
|
|
|
|
|
|
if math.isfinite(number):
|
|
|
|
|
|
numeric_values.setdefault(field, []).append(number)
|
|
|
|
|
|
elif isinstance(value, str) and value:
|
|
|
|
|
|
text_lengths.setdefault(field, []).append(float(len(value)))
|
|
|
|
|
|
|
|
|
|
|
|
numeric_bounds: dict[str, tuple[float, float]] = {}
|
|
|
|
|
|
for field, values in numeric_values.items():
|
|
|
|
|
|
# 小样本不做统计异常判断,避免把合法长尾值误删。
|
|
|
|
|
|
if len(values) < 8:
|
|
|
|
|
|
continue
|
|
|
|
|
|
first_quartile = _percentile(values, 0.25)
|
|
|
|
|
|
third_quartile = _percentile(values, 0.75)
|
|
|
|
|
|
spread = third_quartile - first_quartile
|
|
|
|
|
|
numeric_bounds[field] = (
|
|
|
|
|
|
first_quartile - iqr_multiplier * spread,
|
|
|
|
|
|
third_quartile + iqr_multiplier * spread,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
text_upper_bounds: dict[str, float] = {}
|
|
|
|
|
|
for field, lengths in text_lengths.items():
|
|
|
|
|
|
if len(lengths) < 8:
|
|
|
|
|
|
continue
|
|
|
|
|
|
first_quartile = _percentile(lengths, 0.25)
|
|
|
|
|
|
third_quartile = _percentile(lengths, 0.75)
|
|
|
|
|
|
spread = third_quartile - first_quartile
|
|
|
|
|
|
text_upper_bounds[field] = max(512.0, third_quartile + 3 * spread)
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
accepted: list[ProcessedStructuredRecord] = []
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
|
record = entry.record
|
2026-07-24 11:27:51 +08:00
|
|
|
|
anomalous = False
|
|
|
|
|
|
for field, value in record.items():
|
|
|
|
|
|
if (
|
|
|
|
|
|
isinstance(value, (int, float))
|
|
|
|
|
|
and not isinstance(value, bool)
|
|
|
|
|
|
and not _IDENTITY_FIELD_PATTERN.search(
|
|
|
|
|
|
_normalize_field_name(field, "snake_case")
|
|
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
number = float(value)
|
|
|
|
|
|
if not math.isfinite(number):
|
|
|
|
|
|
anomalous = True
|
|
|
|
|
|
break
|
|
|
|
|
|
bounds = numeric_bounds.get(field)
|
|
|
|
|
|
if bounds and not bounds[0] <= number <= bounds[1]:
|
|
|
|
|
|
anomalous = True
|
|
|
|
|
|
break
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
flags = content_quality_flags(value, min_chars=0, min_tokens=0)
|
|
|
|
|
|
if {"content_too_long", "mojibake", "low_printable_ratio"} & set(flags):
|
|
|
|
|
|
anomalous = True
|
|
|
|
|
|
break
|
|
|
|
|
|
upper_bound = text_upper_bounds.get(field)
|
|
|
|
|
|
if upper_bound is not None and len(value) > upper_bound:
|
|
|
|
|
|
anomalous = True
|
|
|
|
|
|
break
|
|
|
|
|
|
if not anomalous:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
accepted.append(
|
|
|
|
|
|
ProcessedStructuredRecord(
|
|
|
|
|
|
entry.source_index,
|
|
|
|
|
|
deepcopy(dict(record)),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return accepted
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def filter_anomalous_structured_records(
|
|
|
|
|
|
records: Sequence[Mapping[str, Any]],
|
|
|
|
|
|
*,
|
|
|
|
|
|
iqr_multiplier: float = 1.5,
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""按字段级数值 IQR、乱码和极端文本长度过滤异常记录。"""
|
|
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
|
ProcessedStructuredRecord(index, deepcopy(dict(record)))
|
|
|
|
|
|
for index, record in enumerate(records)
|
|
|
|
|
|
]
|
|
|
|
|
|
return [
|
|
|
|
|
|
entry.record
|
|
|
|
|
|
for entry in _filter_anomalous_structured_entries(
|
|
|
|
|
|
entries,
|
|
|
|
|
|
iqr_multiplier=iqr_multiplier,
|
2026-07-24 11:27:51 +08:00
|
|
|
|
)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
]
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def _deduplicate_structured_entries(
|
|
|
|
|
|
entries: Sequence[ProcessedStructuredRecord],
|
|
|
|
|
|
) -> list[ProcessedStructuredRecord]:
|
|
|
|
|
|
"""仅按整条 canonical JSON 稳定去重,避免误删同 ID 的更新记录。"""
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
exact_seen: set[str] = set()
|
2026-07-30 16:53:54 +08:00
|
|
|
|
unique: list[ProcessedStructuredRecord] = []
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
|
record = entry.record
|
2026-07-24 11:27:51 +08:00
|
|
|
|
fingerprint = hashlib.sha256(canonical_record_json(record).encode("utf-8")).hexdigest()
|
2026-07-30 16:53:54 +08:00
|
|
|
|
if fingerprint in exact_seen:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
continue
|
|
|
|
|
|
exact_seen.add(fingerprint)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
unique.append(
|
|
|
|
|
|
ProcessedStructuredRecord(
|
|
|
|
|
|
entry.source_index,
|
|
|
|
|
|
deepcopy(dict(record)),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return unique
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def deduplicate_structured_records(
|
|
|
|
|
|
records: Sequence[Mapping[str, Any]],
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""仅按整条 canonical JSON 稳定去重。"""
|
|
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
|
ProcessedStructuredRecord(index, deepcopy(dict(record)))
|
|
|
|
|
|
for index, record in enumerate(records)
|
|
|
|
|
|
]
|
|
|
|
|
|
return [entry.record for entry in _deduplicate_structured_entries(entries)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def _is_name_field(field: Any) -> bool:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
raw_field = normalize_text(str(field))
|
|
|
|
|
|
if not raw_field:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 只匹配明确表示自然人姓名的字段,避免将 table_name、product_name、
|
|
|
|
|
|
# chinese_name 等业务名称或元数据字段误判为个人敏感信息。
|
|
|
|
|
|
if _normalize_field_name(raw_field, "snake_case") in _NAME_FIELD_NAMES:
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return True
|
2026-07-30 16:53:54 +08:00
|
|
|
|
|
|
|
|
|
|
# detect_structure 会使用点号生成扁平化路径(例如 profile.name);此时仅
|
|
|
|
|
|
# 判断最后一个路径段,不能退回到宽泛的 ``*_name`` 后缀匹配。
|
|
|
|
|
|
if "." not in raw_field:
|
|
|
|
|
|
return False
|
|
|
|
|
|
leaf_field = raw_field.rsplit(".", 1)[-1]
|
|
|
|
|
|
return _normalize_field_name(leaf_field, "snake_case") in _NAME_FIELD_NAMES
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def desensitize_structured_record(
|
|
|
|
|
|
record: Mapping[str, Any],
|
|
|
|
|
|
) -> tuple[dict[str, Any], dict[str, int]]:
|
|
|
|
|
|
"""递归脱敏结构化姓名字段及任意文本中的手机号、邮箱、身份证号。"""
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(record, Mapping):
|
|
|
|
|
|
raise TypeError("record must be a mapping")
|
|
|
|
|
|
counts: dict[str, int] = {"email": 0, "phone": 0, "id_card": 0}
|
|
|
|
|
|
|
|
|
|
|
|
def add_counts(values: Mapping[str, int]) -> None:
|
|
|
|
|
|
for kind, count in values.items():
|
|
|
|
|
|
if kind != "total" and count:
|
|
|
|
|
|
counts[kind] = counts.get(kind, 0) + count
|
|
|
|
|
|
|
|
|
|
|
|
def visit(value: Any, field: Any = "") -> Any:
|
|
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
|
|
return {key: visit(item, key) for key, item in value.items()}
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [visit(item, field) for item in value]
|
|
|
|
|
|
if isinstance(value, tuple):
|
|
|
|
|
|
return [visit(item, field) for item in value]
|
|
|
|
|
|
if _is_name_field(field) and not _is_empty_value(value):
|
|
|
|
|
|
counts["name"] = counts.get("name", 0) + 1
|
|
|
|
|
|
return "[NAME]"
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
masked, found = desensitize_pii(value)
|
|
|
|
|
|
add_counts(found)
|
|
|
|
|
|
return masked
|
|
|
|
|
|
return deepcopy(value)
|
|
|
|
|
|
|
|
|
|
|
|
masked = {key: visit(value, key) for key, value in record.items()}
|
2026-07-23 15:10:13 +08:00
|
|
|
|
counts["total"] = sum(counts.values())
|
|
|
|
|
|
return masked, counts
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def _structured_options(options: Iterable[str] | Mapping[str, Any]) -> set[str]:
|
|
|
|
|
|
if isinstance(options, str):
|
|
|
|
|
|
raise TypeError("options must be an iterable or mapping of option names")
|
|
|
|
|
|
if isinstance(options, Mapping):
|
|
|
|
|
|
enabled = {str(key) for key, value in options.items() if bool(value)}
|
|
|
|
|
|
else:
|
|
|
|
|
|
enabled = {str(option) for option in options}
|
|
|
|
|
|
unknown = enabled - _STRUCTURED_OPTIONS
|
|
|
|
|
|
if unknown:
|
|
|
|
|
|
raise ValueError(f"unsupported structured preprocess options: {', '.join(sorted(unknown))}")
|
|
|
|
|
|
return enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def preprocess_structured_records_with_lineage(
|
2026-07-24 11:27:51 +08:00
|
|
|
|
records: Iterable[Mapping[str, Any]],
|
|
|
|
|
|
options: Iterable[str] | Mapping[str, Any],
|
2026-07-30 16:53:54 +08:00
|
|
|
|
) -> list[ProcessedStructuredRecord]:
|
|
|
|
|
|
"""执行结构化预处理,并保留每条结果在原始输入中的稳定索引。"""
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
enabled = _structured_options(options)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current: list[ProcessedStructuredRecord] = []
|
|
|
|
|
|
for source_index, record in enumerate(records):
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if not isinstance(record, Mapping):
|
|
|
|
|
|
if "clean_invalid" in enabled:
|
|
|
|
|
|
continue
|
|
|
|
|
|
raise TypeError("structured records must contain mappings")
|
|
|
|
|
|
value = deepcopy(dict(record))
|
|
|
|
|
|
if "detect_structure" in enabled:
|
|
|
|
|
|
value = flatten_structured_record(value)
|
|
|
|
|
|
if "normalize_format" in enabled:
|
|
|
|
|
|
value = normalize_structured_record(value)
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current.append(ProcessedStructuredRecord(source_index, value))
|
2026-07-24 11:27:51 +08:00
|
|
|
|
|
|
|
|
|
|
if "clean_invalid" in enabled:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current = _clean_invalid_structured_entries(current)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if "filter_anomaly" in enabled:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current = _filter_anomalous_structured_entries(current)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if "deduplicate" in enabled:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current = _deduplicate_structured_entries(current)
|
2026-07-24 11:27:51 +08:00
|
|
|
|
if "desensitize" in enabled:
|
2026-07-30 16:53:54 +08:00
|
|
|
|
current = [
|
|
|
|
|
|
ProcessedStructuredRecord(
|
|
|
|
|
|
entry.source_index,
|
|
|
|
|
|
desensitize_structured_record(entry.record)[0],
|
|
|
|
|
|
)
|
|
|
|
|
|
for entry in current
|
|
|
|
|
|
]
|
2026-07-24 11:27:51 +08:00
|
|
|
|
return current
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:53:54 +08:00
|
|
|
|
def preprocess_structured_records(
|
|
|
|
|
|
records: Iterable[Mapping[str, Any]],
|
|
|
|
|
|
options: Iterable[str] | Mapping[str, Any],
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""按界面选项执行确定性、无副作用的结构化数据预处理。"""
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
entry.record
|
|
|
|
|
|
for entry in preprocess_structured_records_with_lineage(records, options)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def estimate_token_count(text: str) -> int:
|
|
|
|
|
|
"""无分词器依赖的确定性 token 估算,用于预览与保护性限流。"""
|
|
|
|
|
|
|
|
|
|
|
|
return len(_TOKEN_PATTERN.findall(text))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _protected_markdown_ranges(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
preserve_code_blocks: bool,
|
|
|
|
|
|
preserve_tables: bool,
|
|
|
|
|
|
preserve_lists: bool,
|
|
|
|
|
|
) -> list[tuple[int, int]]:
|
|
|
|
|
|
"""找出不应从中间切开的 Markdown 代码块、表格和连续列表。"""
|
|
|
|
|
|
|
|
|
|
|
|
lines: list[tuple[int, int, str]] = []
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
for raw_line in text.splitlines(keepends=True):
|
|
|
|
|
|
end = cursor + len(raw_line)
|
|
|
|
|
|
lines.append((cursor, end, raw_line.rstrip("\r\n")))
|
|
|
|
|
|
cursor = end
|
|
|
|
|
|
if cursor < len(text) or not lines:
|
|
|
|
|
|
lines.append((cursor, len(text), text[cursor:]))
|
|
|
|
|
|
|
|
|
|
|
|
ranges: list[tuple[int, int]] = []
|
|
|
|
|
|
code_line_indexes: set[int] = set()
|
|
|
|
|
|
if preserve_code_blocks:
|
|
|
|
|
|
open_block: tuple[int, str, int] | None = None
|
|
|
|
|
|
for index, (start, end, content) in enumerate(lines):
|
|
|
|
|
|
fence = re.match(r"^\s*(`{3,}|~{3,})", content)
|
|
|
|
|
|
if not fence:
|
|
|
|
|
|
continue
|
|
|
|
|
|
marker = fence.group(1)[0]
|
|
|
|
|
|
length = len(fence.group(1))
|
|
|
|
|
|
if open_block is None:
|
|
|
|
|
|
open_block = (index, marker, length)
|
|
|
|
|
|
continue
|
|
|
|
|
|
first_index, open_marker, open_length = open_block
|
|
|
|
|
|
if marker == open_marker and length >= open_length:
|
|
|
|
|
|
ranges.append((lines[first_index][0], end))
|
|
|
|
|
|
code_line_indexes.update(range(first_index, index + 1))
|
|
|
|
|
|
open_block = None
|
|
|
|
|
|
if open_block is not None:
|
|
|
|
|
|
first_index = open_block[0]
|
|
|
|
|
|
ranges.append((lines[first_index][0], len(text)))
|
|
|
|
|
|
code_line_indexes.update(range(first_index, len(lines)))
|
|
|
|
|
|
|
|
|
|
|
|
if preserve_tables:
|
|
|
|
|
|
index = 0
|
|
|
|
|
|
while index + 1 < len(lines):
|
|
|
|
|
|
if index in code_line_indexes:
|
|
|
|
|
|
index += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
header = lines[index][2].strip()
|
|
|
|
|
|
separator = lines[index + 1][2].strip().strip("|")
|
|
|
|
|
|
cells = [cell.strip() for cell in separator.split("|")]
|
|
|
|
|
|
if (
|
|
|
|
|
|
"|" not in header
|
|
|
|
|
|
or len(cells) < 2
|
|
|
|
|
|
or not all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)
|
|
|
|
|
|
):
|
|
|
|
|
|
index += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
end_index = index + 1
|
|
|
|
|
|
while (
|
|
|
|
|
|
end_index + 1 < len(lines)
|
|
|
|
|
|
and end_index + 1 not in code_line_indexes
|
|
|
|
|
|
and lines[end_index + 1][2].strip()
|
|
|
|
|
|
and "|" in lines[end_index + 1][2]
|
|
|
|
|
|
):
|
|
|
|
|
|
end_index += 1
|
|
|
|
|
|
ranges.append((lines[index][0], lines[end_index][1]))
|
|
|
|
|
|
index = end_index + 1
|
|
|
|
|
|
|
|
|
|
|
|
if preserve_lists:
|
|
|
|
|
|
list_pattern = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+\S")
|
|
|
|
|
|
continuation_pattern = re.compile(r"^\s{2,}\S")
|
|
|
|
|
|
index = 0
|
|
|
|
|
|
while index < len(lines):
|
|
|
|
|
|
if index in code_line_indexes or not list_pattern.match(lines[index][2]):
|
|
|
|
|
|
index += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
end_index = index
|
|
|
|
|
|
item_count = 1
|
|
|
|
|
|
while end_index + 1 < len(lines) and end_index + 1 not in code_line_indexes:
|
|
|
|
|
|
next_line = lines[end_index + 1][2]
|
|
|
|
|
|
if list_pattern.match(next_line):
|
|
|
|
|
|
item_count += 1
|
|
|
|
|
|
end_index += 1
|
|
|
|
|
|
elif continuation_pattern.match(next_line):
|
|
|
|
|
|
end_index += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
if item_count >= 2:
|
|
|
|
|
|
ranges.append((lines[index][0], lines[end_index][1]))
|
|
|
|
|
|
index = end_index + 1
|
|
|
|
|
|
|
|
|
|
|
|
merged: list[tuple[int, int]] = []
|
|
|
|
|
|
for start, end in sorted(ranges):
|
|
|
|
|
|
if merged and start < merged[-1][1]:
|
|
|
|
|
|
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
|
|
|
|
else:
|
|
|
|
|
|
merged.append((start, end))
|
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:27:51 +08:00
|
|
|
|
def protected_context_ranges(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
preserve_code_blocks: bool = True,
|
|
|
|
|
|
preserve_tables: bool = True,
|
|
|
|
|
|
preserve_lists: bool = True,
|
|
|
|
|
|
) -> tuple[tuple[int, int], ...]:
|
|
|
|
|
|
"""返回代码块、表格和列表的不可拆分区间。
|
|
|
|
|
|
|
|
|
|
|
|
返回的偏移量基于规范化后的文本;调用方应在同一份 ``normalize_text``
|
|
|
|
|
|
结果上使用这些区间。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
normalized = normalize_text(text)
|
|
|
|
|
|
return tuple(
|
|
|
|
|
|
_protected_markdown_ranges(
|
|
|
|
|
|
normalized,
|
|
|
|
|
|
preserve_code_blocks=preserve_code_blocks,
|
|
|
|
|
|
preserve_tables=preserve_tables,
|
|
|
|
|
|
preserve_lists=preserve_lists,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expand_to_context_boundaries(
|
|
|
|
|
|
text: str,
|
|
|
|
|
|
start: int,
|
|
|
|
|
|
end: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
preserve_paragraph: bool = True,
|
|
|
|
|
|
preserve_code_blocks: bool = True,
|
|
|
|
|
|
preserve_tables: bool = True,
|
|
|
|
|
|
preserve_lists: bool = True,
|
|
|
|
|
|
) -> tuple[int, int]:
|
|
|
|
|
|
"""将一个文本区间扩展到段落及受保护 Markdown 结构边界。"""
|
|
|
|
|
|
|
|
|
|
|
|
normalized = normalize_text(text)
|
|
|
|
|
|
if isinstance(start, bool) or isinstance(end, bool):
|
|
|
|
|
|
raise TypeError("start and end must be integers")
|
|
|
|
|
|
if not isinstance(start, int) or not isinstance(end, int):
|
|
|
|
|
|
raise TypeError("start and end must be integers")
|
|
|
|
|
|
if not 0 <= start <= end <= len(normalized):
|
|
|
|
|
|
raise ValueError("start and end must define a valid normalized text range")
|
|
|
|
|
|
|
|
|
|
|
|
expanded_start = start
|
|
|
|
|
|
expanded_end = end
|
|
|
|
|
|
if preserve_paragraph and normalized:
|
|
|
|
|
|
paragraph_start = normalized.rfind("\n\n", 0, start)
|
|
|
|
|
|
expanded_start = 0 if paragraph_start < 0 else paragraph_start + 2
|
|
|
|
|
|
paragraph_end = normalized.find("\n\n", end)
|
|
|
|
|
|
expanded_end = len(normalized) if paragraph_end < 0 else paragraph_end
|
|
|
|
|
|
|
|
|
|
|
|
ranges = protected_context_ranges(
|
|
|
|
|
|
normalized,
|
|
|
|
|
|
preserve_code_blocks=preserve_code_blocks,
|
|
|
|
|
|
preserve_tables=preserve_tables,
|
|
|
|
|
|
preserve_lists=preserve_lists,
|
|
|
|
|
|
)
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
while changed:
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
for range_start, range_end in ranges:
|
|
|
|
|
|
overlaps = range_start < expanded_end and range_end > expanded_start
|
|
|
|
|
|
contains_boundary = (
|
|
|
|
|
|
range_start <= expanded_start < range_end
|
|
|
|
|
|
or range_start < expanded_end <= range_end
|
|
|
|
|
|
)
|
|
|
|
|
|
if not overlaps and not contains_boundary:
|
|
|
|
|
|
continue
|
|
|
|
|
|
next_start = min(expanded_start, range_start)
|
|
|
|
|
|
next_end = max(expanded_end, range_end)
|
|
|
|
|
|
if (next_start, next_end) != (expanded_start, expanded_end):
|
|
|
|
|
|
expanded_start, expanded_end = next_start, next_end
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
return expanded_start, expanded_end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def detect_document_structure(text: str) -> DocumentStructure:
|
|
|
|
|
|
"""识别 Markdown、中文章节和数字编号标题及不可拆分块。"""
|
|
|
|
|
|
|
|
|
|
|
|
normalized = normalize_text(text)
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
return DocumentStructure(
|
|
|
|
|
|
line_count=0,
|
|
|
|
|
|
paragraph_count=0,
|
|
|
|
|
|
headings=(),
|
|
|
|
|
|
code_block_count=0,
|
|
|
|
|
|
table_block_count=0,
|
|
|
|
|
|
list_block_count=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
code_ranges = protected_context_ranges(
|
|
|
|
|
|
normalized,
|
|
|
|
|
|
preserve_code_blocks=True,
|
|
|
|
|
|
preserve_tables=False,
|
|
|
|
|
|
preserve_lists=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
table_candidates = protected_context_ranges(
|
|
|
|
|
|
normalized,
|
|
|
|
|
|
preserve_code_blocks=False,
|
|
|
|
|
|
preserve_tables=True,
|
|
|
|
|
|
preserve_lists=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
list_candidates = protected_context_ranges(
|
|
|
|
|
|
normalized,
|
|
|
|
|
|
preserve_code_blocks=False,
|
|
|
|
|
|
preserve_tables=False,
|
|
|
|
|
|
preserve_lists=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
table_ranges = tuple(
|
|
|
|
|
|
item
|
|
|
|
|
|
for item in table_candidates
|
|
|
|
|
|
if not any(
|
|
|
|
|
|
item[0] < code_end and item[1] > code_start
|
|
|
|
|
|
for code_start, code_end in code_ranges
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
list_ranges = tuple(
|
|
|
|
|
|
item
|
|
|
|
|
|
for item in list_candidates
|
|
|
|
|
|
if not any(
|
|
|
|
|
|
item[0] < code_end and item[1] > code_start
|
|
|
|
|
|
for code_start, code_end in code_ranges
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
markdown_heading = re.compile(r"^\s*(?P<marks>#{1,6})\s+(?P<title>.+?)\s*#*\s*$")
|
|
|
|
|
|
chinese_heading = re.compile(
|
|
|
|
|
|
r"^\s*(?P<title>第[一二三四五六七八九十百千万0-9]+[章节篇部分].*)$"
|
|
|
|
|
|
)
|
|
|
|
|
|
numbered_heading = re.compile(
|
|
|
|
|
|
r"^\s*(?P<number>\d+(?:\.\d+)*)(?:[、.]|\s+)\s*(?P<title>\S.*)$"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
headings: list[DocumentHeading] = []
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
for line_number, raw_line in enumerate(normalized.splitlines(keepends=True), start=1):
|
|
|
|
|
|
line = raw_line.rstrip("\n")
|
|
|
|
|
|
line_end = cursor + len(line)
|
|
|
|
|
|
if not any(range_start <= cursor < range_end for range_start, range_end in code_ranges):
|
|
|
|
|
|
match = markdown_heading.match(line)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
headings.append(
|
|
|
|
|
|
DocumentHeading(
|
|
|
|
|
|
level=len(match.group("marks")),
|
|
|
|
|
|
title=normalize_text(match.group("title")),
|
|
|
|
|
|
line_number=line_number,
|
|
|
|
|
|
start=cursor,
|
|
|
|
|
|
end=line_end,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
match = chinese_heading.match(line)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
headings.append(
|
|
|
|
|
|
DocumentHeading(
|
|
|
|
|
|
level=1,
|
|
|
|
|
|
title=normalize_text(match.group("title")),
|
|
|
|
|
|
line_number=line_number,
|
|
|
|
|
|
start=cursor,
|
|
|
|
|
|
end=line_end,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
match = numbered_heading.match(line)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
headings.append(
|
|
|
|
|
|
DocumentHeading(
|
|
|
|
|
|
level=min(6, match.group("number").count(".") + 1),
|
|
|
|
|
|
title=normalize_text(match.group("title")),
|
|
|
|
|
|
line_number=line_number,
|
|
|
|
|
|
start=cursor,
|
|
|
|
|
|
end=line_end,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
cursor += len(raw_line)
|
|
|
|
|
|
|
|
|
|
|
|
paragraphs = [part for part in re.split(r"\n\s*\n", normalized) if part.strip()]
|
|
|
|
|
|
return DocumentStructure(
|
|
|
|
|
|
line_count=len(normalized.splitlines()),
|
|
|
|
|
|
paragraph_count=len(paragraphs),
|
|
|
|
|
|
headings=tuple(headings),
|
|
|
|
|
|
code_block_count=len(code_ranges),
|
|
|
|
|
|
table_block_count=len(table_ranges),
|
|
|
|
|
|
list_block_count=len(list_ranges),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def merge_short_blocks(
|
|
|
|
|
|
blocks: Iterable[str],
|
|
|
|
|
|
*,
|
|
|
|
|
|
min_token_count: int = 100,
|
|
|
|
|
|
separator: str = "\n\n",
|
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
|
"""按原顺序合并短内容块,并把末尾残块归入前一块。"""
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(min_token_count, bool) or not isinstance(min_token_count, int):
|
|
|
|
|
|
raise TypeError("min_token_count must be an integer")
|
|
|
|
|
|
if min_token_count <= 0:
|
|
|
|
|
|
raise ValueError("min_token_count must be greater than 0")
|
|
|
|
|
|
if not isinstance(separator, str):
|
|
|
|
|
|
raise TypeError("separator must be str")
|
|
|
|
|
|
|
|
|
|
|
|
merged: list[str] = []
|
|
|
|
|
|
pending: list[str] = []
|
|
|
|
|
|
pending_tokens = 0
|
|
|
|
|
|
for block in blocks:
|
|
|
|
|
|
if not isinstance(block, str):
|
|
|
|
|
|
raise TypeError("blocks must contain strings")
|
|
|
|
|
|
normalized = normalize_text(block)
|
|
|
|
|
|
if not normalized:
|
|
|
|
|
|
continue
|
|
|
|
|
|
token_count = estimate_token_count(normalized)
|
|
|
|
|
|
if not pending and token_count >= min_token_count:
|
|
|
|
|
|
merged.append(normalized)
|
|
|
|
|
|
continue
|
|
|
|
|
|
pending.append(normalized)
|
|
|
|
|
|
pending_tokens += token_count
|
|
|
|
|
|
if pending_tokens >= min_token_count:
|
|
|
|
|
|
merged.append(separator.join(pending))
|
|
|
|
|
|
pending = []
|
|
|
|
|
|
pending_tokens = 0
|
|
|
|
|
|
|
|
|
|
|
|
if pending:
|
|
|
|
|
|
tail = separator.join(pending)
|
|
|
|
|
|
if merged:
|
|
|
|
|
|
merged[-1] = separator.join((merged[-1], tail))
|
|
|
|
|
|
else:
|
|
|
|
|
|
merged.append(tail)
|
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _near_duplicate_features(text: str, shingle_size: int) -> tuple[str, ...]:
|
|
|
|
|
|
if isinstance(shingle_size, bool) or not isinstance(shingle_size, int):
|
|
|
|
|
|
raise TypeError("shingle_size must be an integer")
|
|
|
|
|
|
if shingle_size <= 0:
|
|
|
|
|
|
raise ValueError("shingle_size must be greater than 0")
|
|
|
|
|
|
tokens = re.findall(
|
|
|
|
|
|
r"[\u3400-\u4dbf\u4e00-\u9fff]|[A-Za-z0-9_]+",
|
|
|
|
|
|
normalize_text(text).casefold(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not tokens:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
if len(tokens) < shingle_size:
|
|
|
|
|
|
return ("\x1f".join(tokens),)
|
|
|
|
|
|
return tuple(
|
|
|
|
|
|
"\x1f".join(tokens[index : index + shingle_size])
|
|
|
|
|
|
for index in range(len(tokens) - shingle_size + 1)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def near_duplicate_fingerprint(text: str, *, shingle_size: int = 3) -> str:
|
|
|
|
|
|
"""生成 64 位 SimHash 指纹,用于低成本近重复候选筛选。"""
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(shingle_size, bool) or not isinstance(shingle_size, int):
|
|
|
|
|
|
raise TypeError("shingle_size must be an integer")
|
|
|
|
|
|
if shingle_size <= 0:
|
|
|
|
|
|
raise ValueError("shingle_size must be greater than 0")
|
|
|
|
|
|
features = Counter(_near_duplicate_features(text, shingle_size))
|
|
|
|
|
|
if not features:
|
|
|
|
|
|
return "0" * 16
|
|
|
|
|
|
vector = [0] * 64
|
|
|
|
|
|
for feature, weight in features.items():
|
|
|
|
|
|
digest = int.from_bytes(hashlib.sha256(feature.encode("utf-8")).digest()[:8], "big")
|
|
|
|
|
|
for bit in range(64):
|
|
|
|
|
|
vector[bit] += weight if digest & (1 << bit) else -weight
|
|
|
|
|
|
fingerprint = sum(1 << bit for bit, value in enumerate(vector) if value >= 0)
|
|
|
|
|
|
return f"{fingerprint:016x}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fingerprints_are_near_duplicate(
|
|
|
|
|
|
left: str,
|
|
|
|
|
|
right: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
max_hamming_distance: int = 3,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""比较两个 64 位十六进制 SimHash 指纹。"""
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(max_hamming_distance, bool) or not isinstance(max_hamming_distance, int):
|
|
|
|
|
|
raise TypeError("max_hamming_distance must be an integer")
|
|
|
|
|
|
if not 0 <= max_hamming_distance <= 64:
|
|
|
|
|
|
raise ValueError("max_hamming_distance must be in [0, 64]")
|
|
|
|
|
|
if not re.fullmatch(r"[0-9a-fA-F]{16}", left) or not re.fullmatch(
|
|
|
|
|
|
r"[0-9a-fA-F]{16}", right
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("fingerprints must be 16-character hexadecimal strings")
|
|
|
|
|
|
distance = (int(left, 16) ^ int(right, 16)).bit_count()
|
|
|
|
|
|
return distance <= max_hamming_distance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_near_duplicate(
|
|
|
|
|
|
left: str,
|
|
|
|
|
|
right: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
shingle_size: int = 3,
|
|
|
|
|
|
similarity_threshold: float = 0.9,
|
|
|
|
|
|
max_hamming_distance: int = 3,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""结合词片 Jaccard 和 SimHash 判断两段内容是否近重复。"""
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(similarity_threshold, bool) or not isinstance(
|
|
|
|
|
|
similarity_threshold, (int, float)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise TypeError("similarity_threshold must be a number")
|
|
|
|
|
|
if not 0 <= similarity_threshold <= 1:
|
|
|
|
|
|
raise ValueError("similarity_threshold must be in [0, 1]")
|
|
|
|
|
|
if isinstance(max_hamming_distance, bool) or not isinstance(max_hamming_distance, int):
|
|
|
|
|
|
raise TypeError("max_hamming_distance must be an integer")
|
|
|
|
|
|
if not 0 <= max_hamming_distance <= 64:
|
|
|
|
|
|
raise ValueError("max_hamming_distance must be in [0, 64]")
|
|
|
|
|
|
left_normalized = normalize_text(left)
|
|
|
|
|
|
right_normalized = normalize_text(right)
|
|
|
|
|
|
if not left_normalized or not right_normalized:
|
|
|
|
|
|
return left_normalized == right_normalized
|
|
|
|
|
|
if left_normalized.casefold() == right_normalized.casefold():
|
|
|
|
|
|
return True
|
|
|
|
|
|
left_features = set(_near_duplicate_features(left_normalized, shingle_size))
|
|
|
|
|
|
right_features = set(_near_duplicate_features(right_normalized, shingle_size))
|
|
|
|
|
|
union = left_features | right_features
|
|
|
|
|
|
similarity = len(left_features & right_features) / len(union) if union else 1.0
|
|
|
|
|
|
if similarity >= similarity_threshold:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return fingerprints_are_near_duplicate(
|
|
|
|
|
|
near_duplicate_fingerprint(left_normalized, shingle_size=shingle_size),
|
|
|
|
|
|
near_duplicate_fingerprint(right_normalized, shingle_size=shingle_size),
|
|
|
|
|
|
max_hamming_distance=max_hamming_distance,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def record_fingerprint(record: Mapping[str, Any]) -> str:
|
|
|
|
|
|
"""计算与字典键顺序无关的稳定记录指纹。"""
|
|
|
|
|
|
|
|
|
|
|
|
canonical = {
|
|
|
|
|
|
"instruction": normalize_text(str(record.get("instruction") or "")),
|
|
|
|
|
|
"input": normalize_text(str(record.get("input") or "")),
|
|
|
|
|
|
"output": normalize_text(str(record.get("output") or "")),
|
|
|
|
|
|
}
|
|
|
|
|
|
raw = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _readability_score(text: str) -> float:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
nonspace = [char for char in text if not char.isspace()]
|
|
|
|
|
|
if not nonspace:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
printable_ratio = sum(char.isprintable() for char in nonspace) / len(nonspace)
|
|
|
|
|
|
useful_ratio = sum(
|
|
|
|
|
|
char.isalnum() or "\u3400" <= char <= "\u9fff" or unicodedata.category(char).startswith("P")
|
|
|
|
|
|
for char in nonspace
|
|
|
|
|
|
) / len(nonspace)
|
|
|
|
|
|
return round(100 * (0.65 * printable_ratio + 0.35 * useful_ratio), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _internal_duplicate_score(text: str) -> float:
|
|
|
|
|
|
units = [unit.strip().lower() for unit in re.split(r"[\n。!?!?;;]+", text) if unit.strip()]
|
|
|
|
|
|
if len(units) <= 1:
|
|
|
|
|
|
return 100.0
|
|
|
|
|
|
return round(100 * len(set(units)) / len(units), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _source_relevance_score(record: Mapping[str, Any], source_content: str) -> float:
|
|
|
|
|
|
"""估算结果与来源文本的词元覆盖率。
|
|
|
|
|
|
|
|
|
|
|
|
这是无外部模型依赖、可重复的首版评分。没有来源文本(例如人工新增结果)
|
|
|
|
|
|
时不扣分;存在来源时,以结果中的有效词元被来源覆盖的比例计分。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
source = normalize_text(source_content)
|
|
|
|
|
|
if not source:
|
|
|
|
|
|
return 100.0
|
|
|
|
|
|
candidate = normalize_text(
|
|
|
|
|
|
"\n".join(
|
|
|
|
|
|
str(record.get(field) or "") for field in ("instruction", "input", "output")
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def semantic_tokens(text: str) -> set[str]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
token.lower()
|
|
|
|
|
|
for token in _TOKEN_PATTERN.findall(text)
|
|
|
|
|
|
if token.isalnum() or "\u3400" <= token <= "\u9fff"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
source_tokens = semantic_tokens(source)
|
|
|
|
|
|
candidate_tokens = semantic_tokens(candidate)
|
|
|
|
|
|
if not candidate_tokens:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
if not source_tokens:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return round(100 * len(candidate_tokens & source_tokens) / len(candidate_tokens), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def score_quality(
|
|
|
|
|
|
record: Mapping[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
min_output_length: int = 20,
|
|
|
|
|
|
source_content: str = "",
|
|
|
|
|
|
known_fingerprints: Iterable[str] = (),
|
|
|
|
|
|
threshold: float = 60.0,
|
|
|
|
|
|
) -> QualityScore:
|
|
|
|
|
|
"""按完整性、长度、可读性、来源相关性和重复度计算质量分。"""
|
|
|
|
|
|
|
|
|
|
|
|
if min_output_length <= 0:
|
|
|
|
|
|
raise ValueError("min_output_length must be greater than 0")
|
|
|
|
|
|
if not 0 <= threshold <= 100:
|
|
|
|
|
|
raise ValueError("threshold must be in [0, 100]")
|
|
|
|
|
|
|
|
|
|
|
|
instruction = normalize_text(str(record.get("instruction") or ""))
|
|
|
|
|
|
input_text = normalize_text(str(record.get("input") or ""))
|
|
|
|
|
|
output = normalize_text(str(record.get("output") or ""))
|
|
|
|
|
|
flags: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
completeness = 100.0
|
|
|
|
|
|
if not instruction:
|
|
|
|
|
|
completeness -= 50
|
|
|
|
|
|
flags.append("missing_instruction")
|
|
|
|
|
|
if not output:
|
|
|
|
|
|
completeness -= 50
|
|
|
|
|
|
flags.append("missing_output")
|
|
|
|
|
|
|
|
|
|
|
|
output_length = len(output)
|
|
|
|
|
|
length_score = round(min(100.0, output_length / min_output_length * 100), 2)
|
|
|
|
|
|
if output_length < min_output_length:
|
|
|
|
|
|
flags.append("output_too_short")
|
|
|
|
|
|
|
|
|
|
|
|
readability = _readability_score("\n".join((instruction, input_text, output)))
|
|
|
|
|
|
if readability < 70:
|
|
|
|
|
|
flags.append("low_readability")
|
|
|
|
|
|
|
|
|
|
|
|
relevance = _source_relevance_score(record, source_content)
|
|
|
|
|
|
if source_content and relevance < 30:
|
|
|
|
|
|
flags.append("low_source_relevance")
|
|
|
|
|
|
|
|
|
|
|
|
fingerprint = record_fingerprint(record)
|
|
|
|
|
|
known = set(known_fingerprints)
|
|
|
|
|
|
duplicate = 0.0 if fingerprint in known else _internal_duplicate_score(output)
|
|
|
|
|
|
if duplicate == 0:
|
|
|
|
|
|
flags.append("duplicate_record")
|
|
|
|
|
|
elif duplicate < 70:
|
|
|
|
|
|
flags.append("repetitive_output")
|
|
|
|
|
|
|
|
|
|
|
|
overall = round(
|
|
|
|
|
|
completeness * 0.35
|
|
|
|
|
|
+ length_score * 0.20
|
|
|
|
|
|
+ readability * 0.20
|
|
|
|
|
|
+ relevance * 0.15
|
|
|
|
|
|
+ duplicate * 0.10,
|
|
|
|
|
|
2,
|
|
|
|
|
|
)
|
|
|
|
|
|
hard_valid = bool(instruction and output)
|
|
|
|
|
|
return QualityScore(
|
|
|
|
|
|
overall=overall,
|
|
|
|
|
|
completeness=completeness,
|
|
|
|
|
|
length=length_score,
|
|
|
|
|
|
readability=readability,
|
|
|
|
|
|
relevance=relevance,
|
|
|
|
|
|
duplicate=duplicate,
|
|
|
|
|
|
is_valid=hard_valid and overall >= threshold,
|
|
|
|
|
|
flags=tuple(flags),
|
|
|
|
|
|
fingerprint=fingerprint,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stable_split(
|
|
|
|
|
|
value: str | int,
|
|
|
|
|
|
split: Mapping[str, int] | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
seed: str = "",
|
|
|
|
|
|
) -> DatasetSplit:
|
|
|
|
|
|
"""按稳定哈希将记录划分到 train/validation/test。"""
|
|
|
|
|
|
|
|
|
|
|
|
ratios = dict(split or {"train": 80, "validation": 10, "test": 10})
|
|
|
|
|
|
required = {"train", "validation", "test"}
|
|
|
|
|
|
if set(ratios) != required:
|
|
|
|
|
|
raise ValueError("split must contain exactly train, validation and test")
|
|
|
|
|
|
if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in ratios.values()):
|
|
|
|
|
|
raise ValueError("split ratios must be non-negative integers")
|
|
|
|
|
|
if sum(ratios.values()) != 100:
|
|
|
|
|
|
raise ValueError("split ratios must sum to 100")
|
|
|
|
|
|
|
|
|
|
|
|
digest = hashlib.sha256(f"{seed}:{value}".encode("utf-8")).digest()
|
|
|
|
|
|
bucket = int.from_bytes(digest[:8], "big") % 10_000
|
|
|
|
|
|
train_boundary = ratios["train"] * 100
|
|
|
|
|
|
validation_boundary = train_boundary + ratios["validation"] * 100
|
|
|
|
|
|
if bucket < train_boundary:
|
|
|
|
|
|
return "train"
|
|
|
|
|
|
if bucket < validation_boundary:
|
|
|
|
|
|
return "validation"
|
|
|
|
|
|
return "test"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 20:43:47 +08:00
|
|
|
|
def stable_split_assignments(
|
|
|
|
|
|
values: Sequence[str | int],
|
|
|
|
|
|
split: Mapping[str, int] | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
seed: str = "",
|
|
|
|
|
|
) -> list[DatasetSplit]:
|
|
|
|
|
|
"""按稳定顺序和精确配额批量划分数据集。
|
|
|
|
|
|
|
|
|
|
|
|
单条哈希分桶只能在大样本下近似比例。这里先按哈希稳定排序,再用
|
|
|
|
|
|
最大余数法计算各切分配额,确保小数据集也严格遵循配置比例。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
ratios = dict(split or {"train": 80, "validation": 10, "test": 10})
|
|
|
|
|
|
# 复用单条划分的参数校验,避免两套规则逐渐漂移。
|
|
|
|
|
|
stable_split("validation", ratios, seed=seed)
|
|
|
|
|
|
if not values:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
split_order: tuple[DatasetSplit, ...] = ("train", "validation", "test")
|
|
|
|
|
|
exact = {name: len(values) * ratios[name] / 100 for name in split_order}
|
|
|
|
|
|
quotas = {name: math.floor(exact[name]) for name in split_order}
|
|
|
|
|
|
remaining = len(values) - sum(quotas.values())
|
|
|
|
|
|
remainder_order = sorted(
|
|
|
|
|
|
split_order,
|
|
|
|
|
|
key=lambda name: (-(exact[name] - quotas[name]), split_order.index(name)),
|
|
|
|
|
|
)
|
|
|
|
|
|
for name in remainder_order[:remaining]:
|
|
|
|
|
|
quotas[name] += 1
|
|
|
|
|
|
|
|
|
|
|
|
ranked_indices = sorted(
|
|
|
|
|
|
range(len(values)),
|
|
|
|
|
|
key=lambda index: (
|
|
|
|
|
|
hashlib.sha256(f"{seed}:{values[index]}".encode("utf-8")).digest(),
|
|
|
|
|
|
index,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
assignments: list[DatasetSplit] = ["train"] * len(values)
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
for name in split_order:
|
|
|
|
|
|
for index in ranked_indices[cursor : cursor + quotas[name]]:
|
|
|
|
|
|
assignments[index] = name
|
|
|
|
|
|
cursor += quotas[name]
|
|
|
|
|
|
return assignments
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 15:10:13 +08:00
|
|
|
|
def _preview_content(item: Mapping[str, Any]) -> str:
|
|
|
|
|
|
for field in ("edited_content", "editedContent", "original_content", "originalContent", "content"):
|
|
|
|
|
|
value = item.get(field)
|
|
|
|
|
|
if value is not None:
|
|
|
|
|
|
return normalize_text(str(value))
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _standard_fields(content: str) -> tuple[str, str, str]:
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
return "", "", ""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(content)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
payload = None
|
|
|
|
|
|
if isinstance(payload, Mapping):
|
|
|
|
|
|
instruction = next(
|
|
|
|
|
|
(
|
|
|
|
|
|
str(payload[key])
|
|
|
|
|
|
for key in ("instruction", "question", "prompt")
|
|
|
|
|
|
if payload.get(key) is not None
|
|
|
|
|
|
),
|
|
|
|
|
|
"",
|
|
|
|
|
|
)
|
|
|
|
|
|
input_text = next(
|
|
|
|
|
|
(str(payload[key]) for key in ("input", "context") if payload.get(key) is not None),
|
|
|
|
|
|
"",
|
|
|
|
|
|
)
|
|
|
|
|
|
output = next(
|
|
|
|
|
|
(str(payload[key]) for key in ("output", "answer", "response") if payload.get(key) is not None),
|
|
|
|
|
|
"",
|
|
|
|
|
|
)
|
|
|
|
|
|
if instruction or output:
|
|
|
|
|
|
return normalize_text(instruction), normalize_text(input_text), normalize_text(output)
|
|
|
|
|
|
|
|
|
|
|
|
question_answer = re.match(
|
|
|
|
|
|
r"^\s*(?:问|question)\s*[::]\s*(.+?)(?:\n|\r\n?)\s*(?:答|answer)\s*[::]\s*(.+)\s*$",
|
|
|
|
|
|
content,
|
|
|
|
|
|
flags=re.IGNORECASE | re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
if question_answer:
|
|
|
|
|
|
return normalize_text(question_answer.group(1)), "", normalize_text(question_answer.group(2))
|
|
|
|
|
|
|
|
|
|
|
|
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
|
|
|
|
|
first_line = re.sub(r"^(?:问|question)\s*[::]\s*", "", lines[0], flags=re.IGNORECASE)
|
|
|
|
|
|
output = normalize_text("\n".join(lines[1:])) if len(lines) > 1 else normalize_text(content)
|
|
|
|
|
|
return normalize_text(first_line), "", output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_standard_records(
|
|
|
|
|
|
preview_items: Iterable[Mapping[str, Any]],
|
|
|
|
|
|
*,
|
|
|
|
|
|
qa_pairs_per_item: int = 1,
|
|
|
|
|
|
semantic_enrichment: bool = False,
|
|
|
|
|
|
split: Mapping[str, int] | None = None,
|
|
|
|
|
|
split_seed: str = "",
|
|
|
|
|
|
) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""把预览内容确定性转换为标准 instruction/input/output 记录。
|
|
|
|
|
|
|
|
|
|
|
|
该函数只负责本地标准化,不冒充 LLM;服务层可将其作为无模型模式或
|
|
|
|
|
|
LLM 响应解析后的统一落库步骤。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-27 09:11:51 +08:00
|
|
|
|
if not 1 <= qa_pairs_per_item <= MAX_QA_PAIRS_PER_ITEM:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"qa_pairs_per_item must be in [1, {MAX_QA_PAIRS_PER_ITEM}]"
|
|
|
|
|
|
)
|
2026-07-23 15:10:13 +08:00
|
|
|
|
prefixes = (
|
|
|
|
|
|
"请结合实际情况说明:",
|
|
|
|
|
|
"请用通俗易懂的方式说明:",
|
|
|
|
|
|
"请从实际应用角度说明:",
|
|
|
|
|
|
"请简洁自然地说明:",
|
|
|
|
|
|
"请详细解答:",
|
|
|
|
|
|
)
|
|
|
|
|
|
results: list[dict[str, Any]] = []
|
|
|
|
|
|
for item_index, item in enumerate(preview_items):
|
|
|
|
|
|
content = _preview_content(item)
|
|
|
|
|
|
instruction, input_text, output = _standard_fields(content)
|
|
|
|
|
|
preview_id = str(item.get("id") or f"preview-{item_index + 1}")
|
|
|
|
|
|
for variant_index in range(qa_pairs_per_item):
|
|
|
|
|
|
variant_instruction = instruction
|
|
|
|
|
|
if variant_index:
|
|
|
|
|
|
if semantic_enrichment:
|
2026-07-27 09:11:51 +08:00
|
|
|
|
prefix = prefixes[variant_index % len(prefixes)]
|
|
|
|
|
|
if variant_index >= len(prefixes):
|
|
|
|
|
|
prefix = (
|
|
|
|
|
|
f"{prefix.removesuffix(':')}"
|
|
|
|
|
|
f"(问法 {variant_index + 1}):"
|
|
|
|
|
|
)
|
|
|
|
|
|
variant_instruction = f"{prefix}{instruction}"
|
2026-07-23 15:10:13 +08:00
|
|
|
|
else:
|
|
|
|
|
|
variant_instruction = f"{instruction}(问法 {variant_index + 1})"
|
|
|
|
|
|
raw_id = f"{preview_id}:{variant_index + 1}"
|
|
|
|
|
|
result_id = f"result_{hashlib.sha256(raw_id.encode('utf-8')).hexdigest()[:16]}"
|
|
|
|
|
|
status = "valid" if variant_instruction and output else "invalid"
|
|
|
|
|
|
results.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": result_id,
|
|
|
|
|
|
"preview_item_id": preview_id,
|
|
|
|
|
|
"instruction": variant_instruction,
|
|
|
|
|
|
"input": input_text,
|
|
|
|
|
|
"output": output,
|
|
|
|
|
|
"original_instruction": variant_instruction,
|
|
|
|
|
|
"original_input": input_text,
|
|
|
|
|
|
"original_output": output,
|
|
|
|
|
|
"status": status,
|
2026-07-24 20:43:47 +08:00
|
|
|
|
"split": "train",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-07-24 20:43:47 +08:00
|
|
|
|
assignments = stable_split_assignments(
|
|
|
|
|
|
[str(result["id"]) for result in results],
|
|
|
|
|
|
split,
|
|
|
|
|
|
seed=split_seed,
|
|
|
|
|
|
)
|
|
|
|
|
|
for result, assignment in zip(results, assignments, strict=True):
|
|
|
|
|
|
result["split"] = assignment
|
2026-07-23 15:10:13 +08:00
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
|
"DatasetSplit",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"DocumentHeading",
|
2026-07-24 15:05:39 +08:00
|
|
|
|
"DocumentNoiseSpan",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"DocumentStructure",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"ParsedText",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"PdfPageText",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"ProcessedStructuredRecord",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"QualityScore",
|
|
|
|
|
|
"SUPPORTED_TEXT_FORMATS",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"StructuredPreprocessOption",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"TextFormat",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"canonical_record_json",
|
|
|
|
|
|
"content_quality_flags",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"decode_utf8",
|
|
|
|
|
|
"desensitize_pii",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"desensitize_structured_record",
|
|
|
|
|
|
"detect_document_structure",
|
2026-07-24 15:05:39 +08:00
|
|
|
|
"detect_pdf_document_noise",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"detect_text_format",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"deduplicate_structured_records",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"estimate_token_count",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"expand_to_context_boundaries",
|
|
|
|
|
|
"extract_pdf_page_texts",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"extract_structured_records",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"filter_anomalous_structured_records",
|
|
|
|
|
|
"fingerprints_are_near_duplicate",
|
|
|
|
|
|
"flatten_structured_record",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"generate_standard_records",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"is_low_quality_content",
|
|
|
|
|
|
"is_near_duplicate",
|
|
|
|
|
|
"merge_short_blocks",
|
|
|
|
|
|
"near_duplicate_fingerprint",
|
|
|
|
|
|
"normalize_structured_record",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"normalize_text",
|
|
|
|
|
|
"parse_text_content",
|
|
|
|
|
|
"parse_utf8_text",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"preprocess_structured_records",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"preprocess_structured_records_with_lineage",
|
2026-07-24 11:27:51 +08:00
|
|
|
|
"protected_context_ranges",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"record_fingerprint",
|
2026-07-24 15:05:39 +08:00
|
|
|
|
"remove_document_noise",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
"score_quality",
|
|
|
|
|
|
"stable_split",
|
2026-07-24 20:43:47 +08:00
|
|
|
|
"stable_split_assignments",
|
2026-07-30 16:53:54 +08:00
|
|
|
|
"structured_json_dumps",
|
2026-07-23 15:10:13 +08:00
|
|
|
|
]
|