feat:优化增量执行、工具调用容错与评测体系

This commit is contained in:
2026-10-11 04:40:25 -07:00
parent cd5a2b4d8f
commit dadd797391
22 changed files with 2198 additions and 578 deletions
+109 -82
View File
@@ -1,13 +1,17 @@
"""在原始网格里识别表:按空行切块,找表头,合并两级表头,去掉标题、说明和合计行
只用规则;判断不了的地方照常产出表,同时记一条警告,调用方据此提示用户
要看每一行的规则(空行切块、合计行)对整列批量计算;表头、标题、说明只看每块开头或末尾的几行,取成 Python 值判断
"""
import math
import re
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
from app.datafiles.base import (
MAX_TABLE_ROWS,
Reporter,
@@ -16,8 +20,8 @@ from app.datafiles.base import (
rows_exceeded,
where,
)
from app.datafiles.columns import as_text, classify
from app.datafiles.readers import Grid
from app.datafiles.cells import WHITESPACE_CLASS, as_text, classify, mask
from app.datafiles.grid import Column, Grid
from app.schemas.data_file import ParseWarning
# 只在每块的前几行里找表头
@@ -40,6 +44,11 @@ TOTAL_LABELS = {
}
# 例如"华东小计""一季度合计"
TOTAL_SUFFIXES = ("小计", "合计", "总计")
_TOTAL_SET = pa.array(sorted(TOTAL_LABELS))
_TOTAL_STRIP = f"[{WHITESPACE_CLASS}::]"
_TOTAL_SUFFIX = f"(?:{'|'.join(TOTAL_SUFFIXES)})$"
# 每个合计字样(含后缀)至少有其中一个字符;英文按转小写之前的大小写都列出
_TOTAL_HINT = "[计和均tTmMeE]"
# 警告里引用的单元格文本最长显示这么多字
QUOTE_CHARS = 30
@@ -51,8 +60,8 @@ class RawTable:
# 同一 sheet 切出多张表时的序号,从 1 开始;只有一张表时为空
part: int | None
header: list[Any]
# 数据行,宽度与表头相同
rows: list[list[Any]]
# 数据列,和表头一一对应
columns: list[Column]
warnings: list[ParseWarning] = field(default_factory=list)
@@ -62,8 +71,8 @@ class _Piece:
# 表头所在行在网格中的下标
header_row: int
header: list[Any]
# 数据行在网格中的下标
data: list[int]
# 数据行在网格中的范围 [start, stop)
data: list[tuple[int, int]]
warnings: list[ParseWarning]
@@ -74,40 +83,41 @@ def split_tables(
几步都要扫一遍所有行,每步结束报告一次进度,比例来自大表上的实测耗时
"""
rows = _normalize(grid.rows)
report(0.25)
sheet = grid.sheet
present = [column.present() for column in grid.columns]
filled = np.logical_or.reduce(present) if present else np.zeros(grid.height, bool)
blocks = _blocks(filled)
used = _used_columns(present, blocks)
firsts = grid.rows_at(np.array([start for start, _ in blocks], np.int64))
report(0.3)
pieces: list[_Piece] = []
dropped: list[ParseWarning] = []
blocks = _blocks(rows)
report(0.3)
for number, block in enumerate(blocks, start=1):
columns = [
c for c in range(len(rows[0])) if any(rows[r][c] is not None for r in block)
]
for number, ((start, stop), columns, first) in enumerate(
zip(blocks, used, firsts), start=1
):
last = pieces[-1] if pieces else None
# 表中间夹着空行时,后面的块以数据行开头、列也落在上一张表内,接在上一张表后面
if (
last
and not _is_label_row([rows[block[0]][c] for c in columns])
and not _is_label_row([first[c] for c in columns])
and set(columns) <= set(last.columns)
):
last.data.extend(block)
last.data.append((start, stop))
continue
if len(block) < 2:
if stop - start < 2:
dropped.append(
ParseWarning(
code="block_dropped",
message=f"{where(sheet)}第 {block[0] + 1} 行的{_quote(rows[block[0]])}不是表格,已跳过",
message=f"{where(sheet)}第 {start + 1} 行的{_quote(first)}不是表格,已跳过",
)
)
continue
pieces.append(_read_block(rows, block, columns, sheet))
pieces.append(_read_block(grid, start, stop, columns, sheet))
report(0.3 + 0.25 * number / len(blocks))
tables = []
for number, piece in enumerate(pieces, start=1):
table = _finish(rows, piece, sheet)
table = _finish(grid, piece, sheet)
report(0.55 + 0.45 * number / len(pieces))
if table is None:
dropped.append(
@@ -125,41 +135,28 @@ def split_tables(
return tables, dropped
def _normalize(grid: list[list[Any]]) -> list[list[Any]]:
"""去掉字符串两端空白,空字符串记为 None,补齐成同样宽度"""
width = max((len(row) for row in grid), default=0)
rows = []
for row in grid:
cells = [_cell(value) for value in row]
cells.extend([None] * (width - len(cells)))
rows.append(cells)
return rows
def _blocks(filled: np.ndarray) -> list[tuple[int, int]]:
"""连续的非空行组成的块 [start, stop)"""
edges = np.flatnonzero(np.diff(np.concatenate(([0], filled, [0])).astype(np.int8)))
return list(zip(edges[0::2].tolist(), edges[1::2].tolist()))
def _cell(value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
def _blocks(rows: list[list[Any]]) -> list[list[int]]:
blocks, current = [], []
for index, row in enumerate(rows):
if any(value is not None for value in row):
current.append(index)
elif current:
blocks.append(current)
current = []
if current:
blocks.append(current)
return blocks
def _used_columns(
present: list[np.ndarray], blocks: list[tuple[int, int]]
) -> list[list[int]]:
"""每块里有值的列;块之间只隔着空行,按块的起点分段求或即可"""
if not blocks:
return []
starts = np.array([start for start, _ in blocks])
used = np.array([np.logical_or.reduceat(column, starts) for column in present])
return [np.flatnonzero(used[:, index]).tolist() for index in range(len(blocks))]
def _read_block(
rows: list[list[Any]], block: list[int], columns: list[int], sheet: str | None
grid: Grid, start: int, stop: int, columns: list[int], sheet: str | None
) -> _Piece:
cells = [[rows[r][c] for c in columns] for r in block]
# 表头最多在第 HEADER_SEARCH_ROWS 行,两级表头还要看它下面两行
cells = grid.rows(start, min(stop, start + HEADER_SEARCH_ROWS + 2), columns)
need = max(1, math.ceil(len(columns) / 2))
dense = [sum(value is not None for value in row) >= need for row in cells]
search = range(min(HEADER_SEARCH_ROWS, len(cells)))
@@ -173,7 +170,7 @@ def _read_block(
warnings.append(
ParseWarning(
code="header_uncertain",
message=f"{where(sheet)}没有找到明确的表头,用第 {block[header_at] + 1} 行作表头,可能识别错误",
message=f"{where(sheet)}没有找到明确的表头,用第 {start + header_at + 1} 行作表头,可能识别错误",
)
)
if header_at > 0:
@@ -183,7 +180,7 @@ def _read_block(
warnings.append(
ParseWarning(
code="title_skipped",
message=f"{where(sheet)}{_row_range(block[0], block[header_at - 1])}是标题,已跳过:{skipped}",
message=f"{where(sheet)}{_row_range(start, start + header_at - 1)}是标题,已跳过:{skipped}",
)
)
@@ -191,7 +188,7 @@ def _read_block(
data_at = header_at + 1
if (
confident
and header_at + 2 < len(cells)
and header_at + 2 < stop - start
and _is_subheader(cells[header_at], cells[header_at + 1], cells[header_at + 2])
):
header = _merge_header(cells[header_at], cells[header_at + 1])
@@ -199,20 +196,28 @@ def _read_block(
warnings.append(
ParseWarning(
code="header_merged",
message=f"{where(sheet)}{_row_range(block[header_at], block[header_at + 1])}合并为表头",
message=f"{where(sheet)}{_row_range(start + header_at, start + header_at + 1)}合并为表头",
)
)
return _Piece(columns, block[header_at], header, block[data_at:], warnings)
return _Piece(
columns, start + header_at, header, [(start + data_at, stop)], warnings
)
def _finish(rows: list[list[Any]], piece: _Piece, sheet: str | None) -> RawTable | None:
data = [(index, [rows[index][c] for c in piece.columns]) for index in piece.data]
def _finish(grid: Grid, piece: _Piece, sheet: str | None) -> RawTable | None:
ranges = [np.arange(start, stop) for start, stop in piece.data if start < stop]
rows = np.concatenate(ranges) if ranges else np.zeros(0, np.int64)
warnings = piece.warnings
# 表格下方只有一个文字单元格的行是说明,例如"注:数据来源于……"
notes = []
while data and len(piece.columns) >= 3 and _is_note(data[-1][1]):
notes.append(data.pop())
while len(rows) and len(piece.columns) >= 3:
last = int(rows[-1])
row = grid.rows(last, last + 1, piece.columns)[0]
if not _is_note(row):
break
notes.append((last, row))
rows = rows[:-1]
if notes:
notes.reverse()
shown = "".join(_quote(row) for _, row in notes)
@@ -224,22 +229,59 @@ def _finish(rows: list[list[Any]], piece: _Piece, sheet: str | None) -> RawTable
)
# 合计、小计行不是明细数据,留着会让求和重复计算
totals = [index for index, row in data if _is_total(row)]
if totals:
data = [(index, row) for index, row in data if not _is_total(row)]
listed = brief([str(index + 1) for index in totals])
totals = _totals(grid, piece.columns, rows)
if totals.any():
listed = brief([str(index + 1) for index in rows[totals].tolist()])
warnings.append(
ParseWarning(
code="total_rows_removed",
message=f"{where(sheet)}已排除 {len(totals)} 行合计、小计(第 {listed} 行)",
message=f"{where(sheet)}已排除 {int(totals.sum())} 行合计、小计(第 {listed} 行)",
)
)
rows = rows[~totals]
if not data:
if not len(rows):
return None
if len(data) > MAX_TABLE_ROWS:
if len(rows) > MAX_TABLE_ROWS:
raise rows_exceeded(sheet)
return RawTable(sheet, None, piece.header, [row for _, row in data], warnings)
columns = [grid.columns[c].take(rows) for c in piece.columns]
return RawTable(sheet, None, piece.header, columns, warnings)
def _totals(grid: Grid, columns: list[int], rows: np.ndarray) -> np.ndarray:
"""rows 里哪些是合计行:每行只看前两个非空单元格,其中有合计字样的文字就是"""
if not len(rows):
return np.zeros(0, bool)
low, high = int(rows[0]), int(rows[-1]) + 1
seen = np.zeros(high - low, np.int64)
total = np.zeros(high - low, bool)
for c in columns:
column = grid.columns[c].slice(low, high)
present = column.present()
open_ = present & (seen < 2)
if open_.any():
total |= open_ & column.matches(_is_total_label)
seen += present
if (seen >= 2).all():
break
return total[rows - low]
def _is_total_label(strings: pa.Array) -> pa.Array:
"""去掉空白和冒号、转小写后是合计字样;合计字样都含 _TOTAL_HINT 里的字符,先用它筛掉绝大多数值"""
found = np.zeros(len(strings), bool)
rows = np.flatnonzero(mask(pc.match_substring_regex(strings, _TOTAL_HINT)))
if len(rows):
key = pc.utf8_lower(
pc.replace_substring_regex(strings.take(rows), _TOTAL_STRIP, "")
)
found[rows] = mask(
pc.or_(
pc.is_in(key, value_set=_TOTAL_SET),
pc.match_substring_regex(key, _TOTAL_SUFFIX),
)
)
return pa.array(found)
def _is_label(value: Any) -> bool:
@@ -276,21 +318,6 @@ def _is_note(row: list[Any]) -> bool:
return len(values) == 1 and _is_label(values[0])
def _is_total(row: list[Any]) -> bool:
checked = 0
for value in row:
if value is None:
continue
if isinstance(value, str):
key = re.sub(r"[\s::]", "", value).lower()
if key in TOTAL_LABELS or key.endswith(TOTAL_SUFFIXES):
return True
checked += 1
if checked == 2:
return False
return False
def _quote(row: list[Any]) -> str:
"""一行里第一个非空单元格,截断后加引号"""
value = next((value for value in row if value is not None), None)