Re-structure the project.
This commit is contained in:
405
docs/CODE_REVIEW_REPORT.md
Normal file
405
docs/CODE_REVIEW_REPORT.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Invoice Master POC v2 - 代码审查报告
|
||||
|
||||
**审查日期**: 2026-01-22
|
||||
**代码库规模**: 67 个 Python 源文件,约 22,434 行代码
|
||||
**测试覆盖率**: ~40-50%
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
### 总体评估:**良好(B+)**
|
||||
|
||||
**优势**:
|
||||
- ✅ 清晰的模块化架构,职责分离良好
|
||||
- ✅ 使用了合适的数据类和类型提示
|
||||
- ✅ 针对瑞典发票的全面规范化逻辑
|
||||
- ✅ 空间索引优化(O(1) token 查找)
|
||||
- ✅ 完善的降级机制(YOLO 失败时的 OCR fallback)
|
||||
- ✅ 设计良好的 Web API 和 UI
|
||||
|
||||
**主要问题**:
|
||||
- ❌ 支付行解析代码重复(3+ 处)
|
||||
- ❌ 长函数(`_normalize_customer_number` 127 行)
|
||||
- ❌ 配置安全问题(明文数据库密码)
|
||||
- ❌ 异常处理不一致(到处都是通用 Exception)
|
||||
- ❌ 缺少集成测试
|
||||
- ❌ 魔法数字散布各处(0.5, 0.95, 300 等)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构分析
|
||||
|
||||
### 1.1 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── inference/ # 推理管道核心
|
||||
│ ├── pipeline.py (517 行) ⚠️
|
||||
│ ├── field_extractor.py (1,347 行) 🔴 太长
|
||||
│ └── yolo_detector.py
|
||||
├── web/ # FastAPI Web 服务
|
||||
│ ├── app.py (765 行) ⚠️ HTML 内联
|
||||
│ ├── routes.py (184 行)
|
||||
│ └── services.py (286 行)
|
||||
├── ocr/ # OCR 提取
|
||||
│ ├── paddle_ocr.py
|
||||
│ └── machine_code_parser.py (919 行) 🔴 太长
|
||||
├── matcher/ # 字段匹配
|
||||
│ └── field_matcher.py (875 行) ⚠️
|
||||
├── utils/ # 共享工具
|
||||
│ ├── validators.py
|
||||
│ ├── text_cleaner.py
|
||||
│ ├── fuzzy_matcher.py
|
||||
│ ├── ocr_corrections.py
|
||||
│ └── format_variants.py (610 行)
|
||||
├── processing/ # 批处理
|
||||
├── data/ # 数据管理
|
||||
└── cli/ # 命令行工具
|
||||
```
|
||||
|
||||
### 1.2 推理流程
|
||||
|
||||
```
|
||||
PDF/Image 输入
|
||||
↓
|
||||
渲染为图片 (pdf/renderer.py)
|
||||
↓
|
||||
YOLO 检测 (yolo_detector.py) - 检测字段区域
|
||||
↓
|
||||
字段提取 (field_extractor.py)
|
||||
├→ OCR 文本提取 (ocr/paddle_ocr.py)
|
||||
├→ 规范化 & 验证
|
||||
└→ 置信度计算
|
||||
↓
|
||||
交叉验证 (pipeline.py)
|
||||
├→ 解析 payment_line 格式
|
||||
├→ 从 payment_line 提取 OCR/Amount/Account
|
||||
└→ 与检测字段验证,payment_line 值优先
|
||||
↓
|
||||
降级 OCR(如果关键字段缺失)
|
||||
├→ 全页 OCR
|
||||
└→ 正则提取
|
||||
↓
|
||||
InferenceResult 输出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 代码质量问题
|
||||
|
||||
### 2.1 长函数(>50 行)🔴
|
||||
|
||||
| 函数 | 文件 | 行数 | 复杂度 | 问题 |
|
||||
|------|------|------|--------|------|
|
||||
| `_normalize_customer_number()` | field_extractor.py | **127** | 极高 | 4 层模式匹配,7+ 正则,复杂评分 |
|
||||
| `_cross_validate_payment_line()` | pipeline.py | **127** | 极高 | 核心验证逻辑,8+ 条件分支 |
|
||||
| `_normalize_bankgiro()` | field_extractor.py | 62 | 高 | Luhn 验证 + 多种降级 |
|
||||
| `_normalize_plusgiro()` | field_extractor.py | 63 | 高 | 类似 bankgiro |
|
||||
| `_normalize_payment_line()` | field_extractor.py | 74 | 高 | 4 种正则模式 |
|
||||
| `_normalize_amount()` | field_extractor.py | 78 | 高 | 多策略降级 |
|
||||
|
||||
**示例问题** - `_normalize_customer_number()` (第 776-902 行):
|
||||
```python
|
||||
def _normalize_customer_number(self, text: str):
|
||||
# 127 行函数,包含:
|
||||
# - 4 个嵌套的 if/for 循环
|
||||
# - 7 种不同的正则模式
|
||||
# - 5 个评分机制
|
||||
# - 处理有标签和无标签格式
|
||||
```
|
||||
|
||||
**建议**: 拆分为:
|
||||
- `_find_customer_code_patterns()`
|
||||
- `_find_labeled_customer_code()`
|
||||
- `_score_customer_candidates()`
|
||||
|
||||
### 2.2 代码重复 🔴
|
||||
|
||||
**支付行解析(3+ 处重复实现)**:
|
||||
|
||||
1. `_parse_machine_readable_payment_line()` (pipeline.py:217-252)
|
||||
2. `MachineCodeParser.parse()` (machine_code_parser.py:919 行)
|
||||
3. `_normalize_payment_line()` (field_extractor.py:632-705)
|
||||
|
||||
所有三处都实现类似的正则模式:
|
||||
```
|
||||
格式: # <OCR> # <Kronor> <Öre> <Type> > <Account>#<Check>#
|
||||
```
|
||||
|
||||
**Bankgiro/Plusgiro 验证(重复)**:
|
||||
- `validators.py`: `is_valid_bankgiro()`, `format_bankgiro()`
|
||||
- `field_extractor.py`: `_normalize_bankgiro()`, `_normalize_plusgiro()`, `_luhn_checksum()`
|
||||
- `normalizer.py`: `normalize_bankgiro()`, `normalize_plusgiro()`
|
||||
- `field_matcher.py`: 类似匹配逻辑
|
||||
|
||||
**建议**: 创建统一模块:
|
||||
```python
|
||||
# src/common/payment_line_parser.py
|
||||
class PaymentLineParser:
|
||||
def parse(text: str) -> PaymentLineResult
|
||||
|
||||
# src/common/giro_validator.py
|
||||
class GiroValidator:
|
||||
def validate_and_format(value: str, giro_type: str) -> str
|
||||
```
|
||||
|
||||
### 2.3 错误处理不一致 ⚠️
|
||||
|
||||
**通用异常捕获(31 处)**:
|
||||
```python
|
||||
except Exception as e: # 代码库中 31 处
|
||||
result.errors.append(str(e))
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 没有捕获特定错误类型
|
||||
- 通用错误消息丢失上下文
|
||||
- 第 142-147 行 (routes.py): 捕获所有异常,返回 500 状态
|
||||
|
||||
**当前写法** (routes.py:142-147):
|
||||
```python
|
||||
try:
|
||||
service_result = inference_service.process_pdf(...)
|
||||
except Exception as e: # 太宽泛
|
||||
logger.error(f"Error processing document: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
```
|
||||
|
||||
**改进建议**:
|
||||
```python
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=400, detail="PDF 文件未找到")
|
||||
except PyMuPDFError:
|
||||
raise HTTPException(status_code=400, detail="无效的 PDF 格式")
|
||||
except OCRError:
|
||||
raise HTTPException(status_code=503, detail="OCR 服务不可用")
|
||||
```
|
||||
|
||||
### 2.4 配置安全问题 🔴
|
||||
|
||||
**config.py 第 24-30 行** - 明文凭据:
|
||||
```python
|
||||
DATABASE = {
|
||||
'host': '192.168.68.31', # 硬编码 IP
|
||||
'user': 'docmaster', # 硬编码用户名
|
||||
'password': 'nY6LYK5d', # 🔴 明文密码!
|
||||
'database': 'invoice_master'
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:
|
||||
```python
|
||||
DATABASE = {
|
||||
'host': os.getenv('DB_HOST', 'localhost'),
|
||||
'user': os.getenv('DB_USER', 'docmaster'),
|
||||
'password': os.getenv('DB_PASSWORD'), # 从环境变量读取
|
||||
'database': os.getenv('DB_NAME', 'invoice_master')
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 魔法数字 ⚠️
|
||||
|
||||
| 值 | 位置 | 用途 | 问题 |
|
||||
|---|------|------|------|
|
||||
| 0.5 | 多处 | 置信度阈值 | 不可按字段配置 |
|
||||
| 0.95 | pipeline.py | payment_line 置信度 | 无说明 |
|
||||
| 300 | 多处 | DPI | 硬编码 |
|
||||
| 0.1 | field_extractor.py | BBox 填充 | 应为配置 |
|
||||
| 72 | 多处 | PDF 基础 DPI | 公式中的魔法数字 |
|
||||
| 50 | field_extractor.py | 客户编号评分加分 | 无说明 |
|
||||
|
||||
**建议**: 提取到配置:
|
||||
```python
|
||||
INFERENCE_CONFIG = {
|
||||
'confidence_threshold': 0.5,
|
||||
'payment_line_confidence': 0.95,
|
||||
'dpi': 300,
|
||||
'bbox_padding': 0.1,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 命名不一致 ⚠️
|
||||
|
||||
**字段名称不一致**:
|
||||
- YOLO 类名: `invoice_number`, `ocr_number`, `supplier_org_number`
|
||||
- 字段名: `InvoiceNumber`, `OCR`, `supplier_org_number`
|
||||
- CSV 列名: 可能又不同
|
||||
- 数据库字段名: 另一种变体
|
||||
|
||||
映射维护在多处:
|
||||
- `yolo_detector.py` (90-100 行): `CLASS_TO_FIELD`
|
||||
- 多个其他位置
|
||||
|
||||
---
|
||||
|
||||
## 3. 测试分析
|
||||
|
||||
### 3.1 测试覆盖率
|
||||
|
||||
**测试文件**: 13 个
|
||||
- ✅ 覆盖良好: field_matcher, normalizer, payment_line_parser
|
||||
- ⚠️ 中等覆盖: field_extractor, pipeline
|
||||
- ❌ 覆盖不足: web 层, CLI, 批处理
|
||||
|
||||
**估算覆盖率**: 40-50%
|
||||
|
||||
### 3.2 缺失的测试用例 🔴
|
||||
|
||||
**关键缺失**:
|
||||
1. 交叉验证逻辑 - 最复杂部分,测试很少
|
||||
2. payment_line 解析变体 - 多种实现,边界情况不清楚
|
||||
3. OCR 错误纠正 - 不同策略的复杂逻辑
|
||||
4. Web API 端点 - 没有请求/响应测试
|
||||
5. 批处理 - 多 worker 协调未测试
|
||||
6. 降级 OCR 机制 - YOLO 检测失败时
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构风险
|
||||
|
||||
### 🔴 关键风险
|
||||
|
||||
1. **配置安全** - config.py 中明文数据库凭据(24-30 行)
|
||||
2. **错误恢复** - 宽泛的异常处理掩盖真实问题
|
||||
3. **可测试性** - 硬编码依赖阻止单元测试
|
||||
|
||||
### 🟡 高风险
|
||||
|
||||
1. **代码可维护性** - 支付行解析重复
|
||||
2. **可扩展性** - 没有长时间推理的异步处理
|
||||
3. **扩展性** - 添加新字段类型会很困难
|
||||
|
||||
### 🟢 中等风险
|
||||
|
||||
1. **性能** - 懒加载有帮助,但 ORM 查询未优化
|
||||
2. **文档** - 大部分足够但可以更好
|
||||
|
||||
---
|
||||
|
||||
## 5. 优先级矩阵
|
||||
|
||||
| 优先级 | 行动 | 工作量 | 影响 |
|
||||
|--------|------|--------|------|
|
||||
| 🔴 关键 | 修复配置安全(环境变量) | 1 小时 | 高 |
|
||||
| 🔴 关键 | 添加集成测试 | 2-3 天 | 高 |
|
||||
| 🔴 关键 | 文档化错误处理策略 | 4 小时 | 中 |
|
||||
| 🟡 高 | 统一 payment_line 解析 | 1-2 天 | 高 |
|
||||
| 🟡 高 | 提取规范化到子模块 | 2-3 天 | 中 |
|
||||
| 🟡 高 | 添加依赖注入 | 2-3 天 | 中 |
|
||||
| 🟡 高 | 拆分长函数 | 2-3 天 | 低 |
|
||||
| 🟢 中 | 提高测试覆盖率到 70%+ | 3-5 天 | 高 |
|
||||
| 🟢 中 | 提取魔法数字 | 4 小时 | 低 |
|
||||
| 🟢 中 | 标准化命名约定 | 1-2 天 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 具体文件建议
|
||||
|
||||
### 高优先级(代码质量)
|
||||
|
||||
| 文件 | 问题 | 建议 |
|
||||
|------|------|------|
|
||||
| `field_extractor.py` | 1,347 行;6 个长规范化方法 | 拆分为 `normalizers/` 子模块 |
|
||||
| `pipeline.py` | 127 行 `_cross_validate_payment_line()` | 提取到单独的 `CrossValidator` 类 |
|
||||
| `field_matcher.py` | 875 行;复杂匹配逻辑 | 拆分为 `matching/` 子模块 |
|
||||
| `config.py` | 硬编码凭据(第 29 行) | 使用环境变量 |
|
||||
| `machine_code_parser.py` | 919 行;payment_line 解析 | 与 pipeline 解析合并 |
|
||||
|
||||
### 中优先级(重构)
|
||||
|
||||
| 文件 | 问题 | 建议 |
|
||||
|------|------|------|
|
||||
| `app.py` | 765 行;HTML 内联在 Python 中 | 提取到 `templates/` 目录 |
|
||||
| `autolabel.py` | 753 行;批处理逻辑 | 提取 worker 函数到模块 |
|
||||
| `format_variants.py` | 610 行;变体生成 | 考虑策略模式 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议行动
|
||||
|
||||
### 第 1 阶段:关键修复(1 周)
|
||||
|
||||
1. **配置安全** (1 小时)
|
||||
- 移除 config.py 中的明文密码
|
||||
- 添加环境变量支持
|
||||
- 更新 README 说明配置
|
||||
|
||||
2. **错误处理标准化** (1 天)
|
||||
- 定义自定义异常类
|
||||
- 替换通用 Exception 捕获
|
||||
- 添加错误代码常量
|
||||
|
||||
3. **添加关键集成测试** (2 天)
|
||||
- 端到端推理测试
|
||||
- payment_line 交叉验证测试
|
||||
- API 端点测试
|
||||
|
||||
### 第 2 阶段:重构(2-3 周)
|
||||
|
||||
4. **统一 payment_line 解析** (2 天)
|
||||
- 创建 `src/common/payment_line_parser.py`
|
||||
- 合并 3 处重复实现
|
||||
- 迁移所有调用方
|
||||
|
||||
5. **拆分 field_extractor.py** (3 天)
|
||||
- 创建 `src/inference/normalizers/` 子模块
|
||||
- 每个字段类型一个文件
|
||||
- 提取共享验证逻辑
|
||||
|
||||
6. **拆分长函数** (2 天)
|
||||
- `_normalize_customer_number()` → 3 个函数
|
||||
- `_cross_validate_payment_line()` → CrossValidator 类
|
||||
|
||||
### 第 3 阶段:改进(1-2 周)
|
||||
|
||||
7. **提高测试覆盖率** (5 天)
|
||||
- 目标:70%+ 覆盖率
|
||||
- 专注于验证逻辑
|
||||
- 添加边界情况测试
|
||||
|
||||
8. **配置管理改进** (1 天)
|
||||
- 提取所有魔法数字
|
||||
- 创建配置文件(YAML)
|
||||
- 添加配置验证
|
||||
|
||||
9. **文档改进** (2 天)
|
||||
- 添加架构图
|
||||
- 文档化所有私有方法
|
||||
- 创建贡献指南
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:度量指标
|
||||
|
||||
### 代码复杂度
|
||||
|
||||
| 类别 | 计数 | 平均行数 |
|
||||
|------|------|----------|
|
||||
| 源文件 | 67 | 334 |
|
||||
| 长文件 (>500 行) | 12 | 875 |
|
||||
| 长函数 (>50 行) | 23 | 89 |
|
||||
| 测试文件 | 13 | 298 |
|
||||
|
||||
### 依赖关系
|
||||
|
||||
| 类型 | 计数 |
|
||||
|------|------|
|
||||
| 外部依赖 | ~25 |
|
||||
| 内部模块 | 10 |
|
||||
| 循环依赖 | 0 ✅ |
|
||||
|
||||
### 代码风格
|
||||
|
||||
| 指标 | 覆盖率 |
|
||||
|------|--------|
|
||||
| 类型提示 | 80% |
|
||||
| Docstrings (公开) | 80% |
|
||||
| Docstrings (私有) | 40% |
|
||||
| 测试覆盖率 | 45% |
|
||||
|
||||
---
|
||||
|
||||
**生成日期**: 2026-01-22
|
||||
**审查者**: Claude Code
|
||||
**版本**: v2.0
|
||||
96
docs/FIELD_EXTRACTOR_ANALYSIS.md
Normal file
96
docs/FIELD_EXTRACTOR_ANALYSIS.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Field Extractor 分析报告
|
||||
|
||||
## 概述
|
||||
|
||||
field_extractor.py (1183行) 最初被识别为可优化文件,尝试使用 `src/normalize` 模块进行重构,但经过分析和测试后发现 **不应该重构**。
|
||||
|
||||
## 重构尝试
|
||||
|
||||
### 初始计划
|
||||
将 field_extractor.py 中的重复 normalize 方法删除,统一使用 `src/normalize/normalize_field()` 接口。
|
||||
|
||||
### 实施步骤
|
||||
1. ✅ 备份原文件 (`field_extractor_old.py`)
|
||||
2. ✅ 修改 `_normalize_and_validate` 使用统一 normalizer
|
||||
3. ✅ 删除重复的 normalize 方法 (~400行)
|
||||
4. ❌ 运行测试 - **28个失败**
|
||||
5. ✅ 添加 wrapper 方法委托给 normalizer
|
||||
6. ❌ 再次测试 - **12个失败**
|
||||
7. ✅ 还原原文件
|
||||
8. ✅ 测试通过 - **全部45个测试通过**
|
||||
|
||||
## 关键发现
|
||||
|
||||
### 两个模块的不同用途
|
||||
|
||||
| 模块 | 用途 | 输入 | 输出 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| **src/normalize/** | **变体生成** 用于匹配 | 已提取的字段值 | 多个匹配变体列表 | `"INV-12345"` → `["INV-12345", "12345"]` |
|
||||
| **field_extractor** | **值提取** 从OCR文本 | 包含字段的原始OCR文本 | 提取的单个字段值 | `"Fakturanummer: A3861"` → `"A3861"` |
|
||||
|
||||
### 为什么不能统一?
|
||||
|
||||
1. **src/normalize/** 的设计目的:
|
||||
- 接收已经提取的字段值
|
||||
- 生成多个标准化变体用于fuzzy matching
|
||||
- 例如 BankgiroNormalizer:
|
||||
```python
|
||||
normalize("782-1713") → ["7821713", "782-1713"] # 生成变体
|
||||
```
|
||||
|
||||
2. **field_extractor** 的 normalize 方法:
|
||||
- 接收包含字段的原始OCR文本(可能包含标签、其他文本等)
|
||||
- **提取**特定模式的字段值
|
||||
- 例如 `_normalize_bankgiro`:
|
||||
```python
|
||||
_normalize_bankgiro("Bankgiro: 782-1713") → ("782-1713", True, None) # 从文本提取
|
||||
```
|
||||
|
||||
3. **关键区别**:
|
||||
- Normalizer: 变体生成器 (for matching)
|
||||
- Field Extractor: 模式提取器 (for parsing)
|
||||
|
||||
### 测试失败示例
|
||||
|
||||
使用 normalizer 替代 field extractor 方法后的失败:
|
||||
|
||||
```python
|
||||
# InvoiceNumber 测试
|
||||
Input: "Fakturanummer: A3861"
|
||||
期望: "A3861"
|
||||
实际: "Fakturanummer: A3861" # 没有提取,只是清理
|
||||
|
||||
# Bankgiro 测试
|
||||
Input: "Bankgiro: 782-1713"
|
||||
期望: "782-1713"
|
||||
实际: "7821713" # 返回了不带破折号的变体,而不是提取格式化值
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
**field_extractor.py 不应该使用 src/normalize 模块重构**,因为:
|
||||
|
||||
1. ✅ **职责不同**: 提取 vs 变体生成
|
||||
2. ✅ **输入不同**: 包含标签的原始OCR文本 vs 已提取的字段值
|
||||
3. ✅ **输出不同**: 单个提取值 vs 多个匹配变体
|
||||
4. ✅ **现有代码运行良好**: 所有45个测试通过
|
||||
5. ✅ **提取逻辑有价值**: 包含复杂的模式匹配规则(例如区分 Bankgiro/Plusgiro 格式)
|
||||
|
||||
## 建议
|
||||
|
||||
1. **保留 field_extractor.py 原样**: 不进行重构
|
||||
2. **文档化两个模块的差异**: 确保团队理解各自用途
|
||||
3. **关注其他优化目标**: machine_code_parser.py (919行)
|
||||
|
||||
## 学习点
|
||||
|
||||
重构前应该:
|
||||
1. 理解模块的**真实用途**,而不只是看代码相似度
|
||||
2. 运行完整测试套件验证假设
|
||||
3. 评估是否真的存在重复,还是表面相似但用途不同
|
||||
|
||||
---
|
||||
|
||||
**状态**: ✅ 分析完成,决定不重构
|
||||
**测试**: ✅ 45/45 通过
|
||||
**文件**: 保持 1183行 原样
|
||||
238
docs/MACHINE_CODE_PARSER_ANALYSIS.md
Normal file
238
docs/MACHINE_CODE_PARSER_ANALYSIS.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Machine Code Parser 分析报告
|
||||
|
||||
## 文件概况
|
||||
|
||||
- **文件**: `src/ocr/machine_code_parser.py`
|
||||
- **总行数**: 919 行
|
||||
- **代码行**: 607 行 (66%)
|
||||
- **方法数**: 14 个
|
||||
- **正则表达式使用**: 47 次
|
||||
|
||||
## 代码结构
|
||||
|
||||
### 类结构
|
||||
|
||||
```
|
||||
MachineCodeResult (数据类)
|
||||
├── to_dict()
|
||||
└── get_region_bbox()
|
||||
|
||||
MachineCodeParser (主解析器)
|
||||
├── __init__()
|
||||
├── parse() - 主入口
|
||||
├── _find_tokens_with_values()
|
||||
├── _find_machine_code_line_tokens()
|
||||
├── _parse_standard_payment_line_with_tokens()
|
||||
├── _parse_standard_payment_line() - 142行 ⚠️
|
||||
├── _extract_ocr() - 50行
|
||||
├── _extract_bankgiro() - 58行
|
||||
├── _extract_plusgiro() - 30行
|
||||
├── _extract_amount() - 68行
|
||||
├── _calculate_confidence()
|
||||
└── cross_validate()
|
||||
```
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### 1. ⚠️ `_parse_standard_payment_line` 方法过长 (142行)
|
||||
|
||||
**位置**: 442-582 行
|
||||
|
||||
**问题**:
|
||||
- 包含嵌套函数 `normalize_account_spaces` 和 `format_account`
|
||||
- 多个正则匹配分支
|
||||
- 逻辑复杂,难以测试和维护
|
||||
|
||||
**建议**:
|
||||
可以拆分为独立方法:
|
||||
- `_normalize_account_spaces(line)`
|
||||
- `_format_account(account_digits, context)`
|
||||
- `_match_primary_pattern(line)`
|
||||
- `_match_fallback_patterns(line)`
|
||||
|
||||
### 2. 🔁 4个 `_extract_*` 方法有重复模式
|
||||
|
||||
所有 extract 方法都遵循相同模式:
|
||||
|
||||
```python
|
||||
def _extract_XXX(self, tokens):
|
||||
candidates = []
|
||||
|
||||
for token in tokens:
|
||||
text = token.text.strip()
|
||||
matches = self.XXX_PATTERN.findall(text)
|
||||
for match in matches:
|
||||
# 验证逻辑
|
||||
# 上下文检测
|
||||
candidates.append((normalized, context_score, token))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: (x[1], 1), reverse=True)
|
||||
return candidates[0][0]
|
||||
```
|
||||
|
||||
**重复的逻辑**:
|
||||
- Token 迭代
|
||||
- 模式匹配
|
||||
- 候选收集
|
||||
- 上下文评分
|
||||
- 排序和选择最佳匹配
|
||||
|
||||
**建议**:
|
||||
可以提取基础提取器类或通用方法来减少重复。
|
||||
|
||||
### 3. ✅ 上下文检测重复
|
||||
|
||||
上下文检测代码在多个地方重复:
|
||||
|
||||
```python
|
||||
# _extract_bankgiro 中
|
||||
context_text = ' '.join(t.text.lower() for t in tokens)
|
||||
is_bankgiro_context = (
|
||||
'bankgiro' in context_text or
|
||||
'bg:' in context_text or
|
||||
'bg ' in context_text
|
||||
)
|
||||
|
||||
# _extract_plusgiro 中
|
||||
context_text = ' '.join(t.text.lower() for t in tokens)
|
||||
is_plusgiro_context = (
|
||||
'plusgiro' in context_text or
|
||||
'postgiro' in context_text or
|
||||
'pg:' in context_text or
|
||||
'pg ' in context_text
|
||||
)
|
||||
|
||||
# _parse_standard_payment_line 中
|
||||
context = (context_line or raw_line).lower()
|
||||
is_plusgiro_context = (
|
||||
('plusgiro' in context or 'postgiro' in context or 'plusgirokonto' in context)
|
||||
and 'bankgiro' not in context
|
||||
)
|
||||
```
|
||||
|
||||
**建议**:
|
||||
提取为独立方法:
|
||||
- `_detect_account_context(tokens) -> dict[str, bool]`
|
||||
|
||||
## 重构建议
|
||||
|
||||
### 方案 A: 轻度重构(推荐)✅
|
||||
|
||||
**目标**: 提取重复的上下文检测逻辑,不改变主要结构
|
||||
|
||||
**步骤**:
|
||||
1. 提取 `_detect_account_context(tokens)` 方法
|
||||
2. 提取 `_normalize_account_spaces(line)` 为独立方法
|
||||
3. 提取 `_format_account(digits, context)` 为独立方法
|
||||
|
||||
**影响**:
|
||||
- 减少 ~50-80 行重复代码
|
||||
- 提高可测试性
|
||||
- 低风险,易于验证
|
||||
|
||||
**预期结果**: 919 行 → ~850 行 (↓7%)
|
||||
|
||||
### 方案 B: 中度重构
|
||||
|
||||
**目标**: 创建通用的字段提取框架
|
||||
|
||||
**步骤**:
|
||||
1. 创建 `_generic_extract(pattern, normalizer, context_checker)`
|
||||
2. 重构所有 `_extract_*` 方法使用通用框架
|
||||
3. 拆分 `_parse_standard_payment_line` 为多个小方法
|
||||
|
||||
**影响**:
|
||||
- 减少 ~150-200 行代码
|
||||
- 显著提高可维护性
|
||||
- 中等风险,需要全面测试
|
||||
|
||||
**预期结果**: 919 行 → ~720 行 (↓22%)
|
||||
|
||||
### 方案 C: 深度重构(不推荐)
|
||||
|
||||
**目标**: 完全重新设计为策略模式
|
||||
|
||||
**风险**:
|
||||
- 高风险,可能引入 bugs
|
||||
- 需要大量测试
|
||||
- 可能破坏现有集成
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### ✅ 采用方案 A(轻度重构)
|
||||
|
||||
**理由**:
|
||||
1. **代码已经工作良好**: 没有明显的 bug 或性能问题
|
||||
2. **低风险**: 只提取重复逻辑,不改变核心算法
|
||||
3. **性价比高**: 小改动带来明显的代码质量提升
|
||||
4. **易于验证**: 现有测试应该能覆盖
|
||||
|
||||
### 重构步骤
|
||||
|
||||
```python
|
||||
# 1. 提取上下文检测
|
||||
def _detect_account_context(self, tokens: list[TextToken]) -> dict[str, bool]:
|
||||
"""检测上下文中的账户类型关键词"""
|
||||
context_text = ' '.join(t.text.lower() for t in tokens)
|
||||
|
||||
return {
|
||||
'bankgiro': any(kw in context_text for kw in ['bankgiro', 'bg:', 'bg ']),
|
||||
'plusgiro': any(kw in context_text for kw in ['plusgiro', 'postgiro', 'plusgirokonto', 'pg:', 'pg ']),
|
||||
}
|
||||
|
||||
# 2. 提取空格标准化
|
||||
def _normalize_account_spaces(self, line: str) -> str:
|
||||
"""移除账户号码中的空格"""
|
||||
# (现有 line 460-481 的代码)
|
||||
|
||||
# 3. 提取账户格式化
|
||||
def _format_account(
|
||||
self,
|
||||
account_digits: str,
|
||||
is_plusgiro_context: bool
|
||||
) -> tuple[str, str]:
|
||||
"""格式化账户并确定类型"""
|
||||
# (现有 line 485-523 的代码)
|
||||
```
|
||||
|
||||
## 对比:field_extractor vs machine_code_parser
|
||||
|
||||
| 特征 | field_extractor | machine_code_parser |
|
||||
|------|-----------------|---------------------|
|
||||
| 用途 | 值提取 | 机器码解析 |
|
||||
| 重复代码 | ~400行normalize方法 | ~80行上下文检测 |
|
||||
| 重构价值 | ❌ 不同用途,不应统一 | ✅ 可提取共享逻辑 |
|
||||
| 风险 | 高(会破坏功能) | 低(只是代码组织) |
|
||||
|
||||
## 决策
|
||||
|
||||
### ✅ 建议重构 machine_code_parser.py
|
||||
|
||||
**与 field_extractor 的不同**:
|
||||
- field_extractor: 重复的方法有**不同的用途**(提取 vs 变体生成)
|
||||
- machine_code_parser: 重复的代码有**相同的用途**(都是上下文检测)
|
||||
|
||||
**预期收益**:
|
||||
- 减少 ~70 行重复代码
|
||||
- 提高可测试性(可以单独测试上下文检测)
|
||||
- 更清晰的代码组织
|
||||
- **低风险**,易于验证
|
||||
|
||||
## 下一步
|
||||
|
||||
1. ✅ 备份原文件
|
||||
2. ✅ 提取 `_detect_account_context` 方法
|
||||
3. ✅ 提取 `_normalize_account_spaces` 方法
|
||||
4. ✅ 提取 `_format_account` 方法
|
||||
5. ✅ 更新所有调用点
|
||||
6. ✅ 运行测试验证
|
||||
7. ✅ 检查代码覆盖率
|
||||
|
||||
---
|
||||
|
||||
**状态**: 📋 分析完成,建议轻度重构
|
||||
**风险评估**: 🟢 低风险
|
||||
**预期收益**: 919行 → ~850行 (↓7%)
|
||||
519
docs/PERFORMANCE_OPTIMIZATION.md
Normal file
519
docs/PERFORMANCE_OPTIMIZATION.md
Normal file
@@ -0,0 +1,519 @@
|
||||
# Performance Optimization Guide
|
||||
|
||||
This document provides performance optimization recommendations for the Invoice Field Extraction system.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Batch Processing Optimization](#batch-processing-optimization)
|
||||
2. [Database Query Optimization](#database-query-optimization)
|
||||
3. [Caching Strategies](#caching-strategies)
|
||||
4. [Memory Management](#memory-management)
|
||||
5. [Profiling and Monitoring](#profiling-and-monitoring)
|
||||
|
||||
---
|
||||
|
||||
## Batch Processing Optimization
|
||||
|
||||
### Current State
|
||||
|
||||
The system processes invoices one at a time. For large batches, this can be inefficient.
|
||||
|
||||
### Recommendations
|
||||
|
||||
#### 1. Database Batch Operations
|
||||
|
||||
**Current**: Individual inserts for each document
|
||||
```python
|
||||
# Inefficient
|
||||
for doc in documents:
|
||||
db.insert_document(doc) # Individual DB call
|
||||
```
|
||||
|
||||
**Optimized**: Use `execute_values` for batch inserts
|
||||
```python
|
||||
# Efficient - already implemented in db.py line 519
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
execute_values(cursor, """
|
||||
INSERT INTO documents (...)
|
||||
VALUES %s
|
||||
""", document_values)
|
||||
```
|
||||
|
||||
**Impact**: 10-50x faster for batches of 100+ documents
|
||||
|
||||
#### 2. PDF Processing Batching
|
||||
|
||||
**Recommendation**: Process PDFs in parallel using multiprocessing
|
||||
|
||||
```python
|
||||
from multiprocessing import Pool
|
||||
|
||||
def process_batch(pdf_paths, batch_size=10):
|
||||
"""Process PDFs in parallel batches."""
|
||||
with Pool(processes=batch_size) as pool:
|
||||
results = pool.map(pipeline.process_pdf, pdf_paths)
|
||||
return results
|
||||
```
|
||||
|
||||
**Considerations**:
|
||||
- GPU models should use a shared process pool (already exists: `src/processing/gpu_pool.py`)
|
||||
- CPU-intensive tasks can use separate process pool (`src/processing/cpu_pool.py`)
|
||||
- Current dual pool coordinator (`dual_pool_coordinator.py`) already supports this pattern
|
||||
|
||||
**Status**: ✅ Already implemented in `src/processing/` modules
|
||||
|
||||
#### 3. Image Caching for Multi-Page PDFs
|
||||
|
||||
**Current**: Each page rendered independently
|
||||
```python
|
||||
# Current pattern in field_extractor.py
|
||||
for page_num in range(total_pages):
|
||||
image = render_pdf_page(pdf_path, page_num, dpi=300)
|
||||
```
|
||||
|
||||
**Optimized**: Pre-render all pages if processing multiple fields per page
|
||||
```python
|
||||
# Batch render
|
||||
images = {
|
||||
page_num: render_pdf_page(pdf_path, page_num, dpi=300)
|
||||
for page_num in page_numbers_needed
|
||||
}
|
||||
|
||||
# Reuse images
|
||||
for detection in detections:
|
||||
image = images[detection.page_no]
|
||||
extract_field(detection, image)
|
||||
```
|
||||
|
||||
**Impact**: Reduces redundant PDF rendering by 50-90% for multi-field invoices
|
||||
|
||||
---
|
||||
|
||||
## Database Query Optimization
|
||||
|
||||
### Current Performance
|
||||
|
||||
- **Parameterized queries**: ✅ Implemented (Phase 1)
|
||||
- **Connection pooling**: ❌ Not implemented
|
||||
- **Query batching**: ✅ Partially implemented
|
||||
- **Index optimization**: ⚠️ Needs verification
|
||||
|
||||
### Recommendations
|
||||
|
||||
#### 1. Connection Pooling
|
||||
|
||||
**Current**: New connection for each operation
|
||||
```python
|
||||
def connect(self):
|
||||
"""Create new database connection."""
|
||||
return psycopg2.connect(**self.config)
|
||||
```
|
||||
|
||||
**Optimized**: Use connection pooling
|
||||
```python
|
||||
from psycopg2 import pool
|
||||
|
||||
class DocumentDatabase:
|
||||
def __init__(self, config):
|
||||
self.pool = pool.SimpleConnectionPool(
|
||||
minconn=1,
|
||||
maxconn=10,
|
||||
**config
|
||||
)
|
||||
|
||||
def connect(self):
|
||||
return self.pool.getconn()
|
||||
|
||||
def close(self, conn):
|
||||
self.pool.putconn(conn)
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Reduces connection overhead by 80-95%
|
||||
- Especially important for high-frequency operations
|
||||
|
||||
#### 2. Index Recommendations
|
||||
|
||||
**Check current indexes**:
|
||||
```sql
|
||||
-- Verify indexes exist on frequently queried columns
|
||||
SELECT tablename, indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public';
|
||||
```
|
||||
|
||||
**Recommended indexes**:
|
||||
```sql
|
||||
-- If not already present
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_success
|
||||
ON documents(success);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_timestamp
|
||||
ON documents(timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_results_document_id
|
||||
ON field_results(document_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_results_matched
|
||||
ON field_results(matched);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_field_results_field_name
|
||||
ON field_results(field_name);
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- 10-100x faster queries for filtered/sorted results
|
||||
- Critical for `get_failed_matches()` and `get_all_documents_summary()`
|
||||
|
||||
#### 3. Query Batching
|
||||
|
||||
**Status**: ✅ Already implemented for field results (line 519)
|
||||
|
||||
**Verify batching is used**:
|
||||
```python
|
||||
# Good pattern in db.py
|
||||
execute_values(cursor, "INSERT INTO field_results (...) VALUES %s", field_values)
|
||||
```
|
||||
|
||||
**Additional opportunity**: Batch `SELECT` queries
|
||||
```python
|
||||
# Current
|
||||
docs = [get_document(doc_id) for doc_id in doc_ids] # N queries
|
||||
|
||||
# Optimized
|
||||
docs = get_documents_batch(doc_ids) # 1 query with IN clause
|
||||
```
|
||||
|
||||
**Status**: ✅ Already implemented (`get_documents_batch` exists in db.py)
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### 1. Model Loading Cache
|
||||
|
||||
**Current**: Models loaded per-instance
|
||||
|
||||
**Recommendation**: Singleton pattern for YOLO model
|
||||
```python
|
||||
class YOLODetectorSingleton:
|
||||
_instance = None
|
||||
_model = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, model_path):
|
||||
if cls._instance is None:
|
||||
cls._instance = YOLODetector(model_path)
|
||||
return cls._instance
|
||||
```
|
||||
|
||||
**Impact**: Reduces memory usage by 90% when processing multiple documents
|
||||
|
||||
### 2. Parser Instance Caching
|
||||
|
||||
**Current**: ✅ Already optimal
|
||||
```python
|
||||
# Good pattern in field_extractor.py
|
||||
def __init__(self):
|
||||
self.payment_line_parser = PaymentLineParser() # Reused
|
||||
self.customer_number_parser = CustomerNumberParser() # Reused
|
||||
```
|
||||
|
||||
**Status**: No changes needed
|
||||
|
||||
### 3. OCR Result Caching
|
||||
|
||||
**Recommendation**: Cache OCR results for identical regions
|
||||
```python
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=1000)
|
||||
def ocr_region_cached(image_hash, bbox):
|
||||
"""Cache OCR results by image hash + bbox."""
|
||||
return paddle_ocr.ocr_region(image, bbox)
|
||||
```
|
||||
|
||||
**Impact**: 50-80% speedup when re-processing similar documents
|
||||
|
||||
**Note**: Requires implementing image hashing (e.g., `hashlib.md5(image.tobytes())`)
|
||||
|
||||
---
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Current Issues
|
||||
|
||||
**Potential memory leaks**:
|
||||
1. Large images kept in memory after processing
|
||||
2. OCR results accumulated without cleanup
|
||||
3. Model outputs not explicitly cleared
|
||||
|
||||
### Recommendations
|
||||
|
||||
#### 1. Explicit Image Cleanup
|
||||
|
||||
```python
|
||||
import gc
|
||||
|
||||
def process_pdf(pdf_path):
|
||||
try:
|
||||
image = render_pdf(pdf_path)
|
||||
result = extract_fields(image)
|
||||
return result
|
||||
finally:
|
||||
del image # Explicit cleanup
|
||||
gc.collect() # Force garbage collection
|
||||
```
|
||||
|
||||
#### 2. Generator Pattern for Large Batches
|
||||
|
||||
**Current**: Load all documents into memory
|
||||
```python
|
||||
docs = [process_pdf(path) for path in pdf_paths] # All in memory
|
||||
```
|
||||
|
||||
**Optimized**: Use generator for streaming processing
|
||||
```python
|
||||
def process_batch_streaming(pdf_paths):
|
||||
"""Process documents one at a time, yielding results."""
|
||||
for path in pdf_paths:
|
||||
result = process_pdf(path)
|
||||
yield result
|
||||
# Result can be saved to DB immediately
|
||||
# Previous result is garbage collected
|
||||
```
|
||||
|
||||
**Impact**: Constant memory usage regardless of batch size
|
||||
|
||||
#### 3. Context Managers for Resources
|
||||
|
||||
```python
|
||||
class InferencePipeline:
|
||||
def __enter__(self):
|
||||
self.detector.load_model()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.detector.unload_model()
|
||||
self.extractor.cleanup()
|
||||
|
||||
# Usage
|
||||
with InferencePipeline(...) as pipeline:
|
||||
results = pipeline.process_pdf(path)
|
||||
# Automatic cleanup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profiling and Monitoring
|
||||
|
||||
### Recommended Profiling Tools
|
||||
|
||||
#### 1. cProfile for CPU Profiling
|
||||
|
||||
```python
|
||||
import cProfile
|
||||
import pstats
|
||||
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
|
||||
# Your code here
|
||||
pipeline.process_pdf(pdf_path)
|
||||
|
||||
profiler.disable()
|
||||
stats = pstats.Stats(profiler)
|
||||
stats.sort_stats('cumulative')
|
||||
stats.print_stats(20) # Top 20 slowest functions
|
||||
```
|
||||
|
||||
#### 2. memory_profiler for Memory Analysis
|
||||
|
||||
```bash
|
||||
pip install memory_profiler
|
||||
python -m memory_profiler your_script.py
|
||||
```
|
||||
|
||||
Or decorator-based:
|
||||
```python
|
||||
from memory_profiler import profile
|
||||
|
||||
@profile
|
||||
def process_large_batch(pdf_paths):
|
||||
# Memory usage tracked line-by-line
|
||||
results = [process_pdf(path) for path in pdf_paths]
|
||||
return results
|
||||
```
|
||||
|
||||
#### 3. py-spy for Production Profiling
|
||||
|
||||
```bash
|
||||
pip install py-spy
|
||||
|
||||
# Profile running process
|
||||
py-spy top --pid 12345
|
||||
|
||||
# Generate flamegraph
|
||||
py-spy record -o profile.svg -- python your_script.py
|
||||
```
|
||||
|
||||
**Advantage**: No code changes needed, minimal overhead
|
||||
|
||||
### Key Metrics to Monitor
|
||||
|
||||
1. **Processing Time per Document**
|
||||
- Target: <10 seconds for single-page invoice
|
||||
- Current: ~2-5 seconds (estimated)
|
||||
|
||||
2. **Memory Usage**
|
||||
- Target: <2GB for batch of 100 documents
|
||||
- Monitor: Peak memory usage
|
||||
|
||||
3. **Database Query Time**
|
||||
- Target: <100ms per query (with indexes)
|
||||
- Monitor: Slow query log
|
||||
|
||||
4. **OCR Accuracy vs Speed Trade-off**
|
||||
- Current: PaddleOCR with GPU (~200ms per region)
|
||||
- Alternative: Tesseract (~500ms, slightly more accurate)
|
||||
|
||||
### Logging Performance Metrics
|
||||
|
||||
**Add to pipeline.py**:
|
||||
```python
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process_pdf(self, pdf_path):
|
||||
start = time.time()
|
||||
|
||||
# Processing...
|
||||
result = self._process_internal(pdf_path)
|
||||
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"Processed {pdf_path} in {elapsed:.2f}s")
|
||||
|
||||
# Log to database for analysis
|
||||
self.db.log_performance({
|
||||
'document_id': result.document_id,
|
||||
'processing_time': elapsed,
|
||||
'field_count': len(result.fields)
|
||||
})
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Priorities
|
||||
|
||||
### High Priority (Implement First)
|
||||
|
||||
1. ✅ **Database parameterized queries** - Already done (Phase 1)
|
||||
2. ⚠️ **Database connection pooling** - Not implemented
|
||||
3. ⚠️ **Index optimization** - Needs verification
|
||||
|
||||
### Medium Priority
|
||||
|
||||
4. ⚠️ **Batch PDF rendering** - Optimization possible
|
||||
5. ✅ **Parser instance reuse** - Already done (Phase 2)
|
||||
6. ⚠️ **Model caching** - Could improve
|
||||
|
||||
### Low Priority (Nice to Have)
|
||||
|
||||
7. ⚠️ **OCR result caching** - Complex implementation
|
||||
8. ⚠️ **Generator patterns** - Refactoring needed
|
||||
9. ⚠️ **Advanced profiling** - For production optimization
|
||||
|
||||
---
|
||||
|
||||
## Benchmarking Script
|
||||
|
||||
```python
|
||||
"""
|
||||
Benchmark script for invoice processing performance.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from src.inference.pipeline import InferencePipeline
|
||||
|
||||
def benchmark_single_document(pdf_path, iterations=10):
|
||||
"""Benchmark single document processing."""
|
||||
pipeline = InferencePipeline(
|
||||
model_path="path/to/model.pt",
|
||||
use_gpu=True
|
||||
)
|
||||
|
||||
times = []
|
||||
for i in range(iterations):
|
||||
start = time.time()
|
||||
result = pipeline.process_pdf(pdf_path)
|
||||
elapsed = time.time() - start
|
||||
times.append(elapsed)
|
||||
print(f"Iteration {i+1}: {elapsed:.2f}s")
|
||||
|
||||
avg_time = sum(times) / len(times)
|
||||
print(f"\nAverage: {avg_time:.2f}s")
|
||||
print(f"Min: {min(times):.2f}s")
|
||||
print(f"Max: {max(times):.2f}s")
|
||||
|
||||
def benchmark_batch(pdf_paths, batch_size=10):
|
||||
"""Benchmark batch processing."""
|
||||
from multiprocessing import Pool
|
||||
|
||||
pipeline = InferencePipeline(
|
||||
model_path="path/to/model.pt",
|
||||
use_gpu=True
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
|
||||
with Pool(processes=batch_size) as pool:
|
||||
results = pool.map(pipeline.process_pdf, pdf_paths)
|
||||
|
||||
elapsed = time.time() - start
|
||||
avg_per_doc = elapsed / len(pdf_paths)
|
||||
|
||||
print(f"Total time: {elapsed:.2f}s")
|
||||
print(f"Documents: {len(pdf_paths)}")
|
||||
print(f"Average per document: {avg_per_doc:.2f}s")
|
||||
print(f"Throughput: {len(pdf_paths)/elapsed:.2f} docs/sec")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Single document benchmark
|
||||
benchmark_single_document("test.pdf")
|
||||
|
||||
# Batch benchmark
|
||||
pdf_paths = list(Path("data/test_pdfs").glob("*.pdf"))
|
||||
benchmark_batch(pdf_paths[:100])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Implemented (Phase 1-2)**:
|
||||
- ✅ Parameterized queries (SQL injection fix)
|
||||
- ✅ Parser instance reuse (Phase 2 refactoring)
|
||||
- ✅ Batch insert operations (execute_values)
|
||||
- ✅ Dual pool processing (CPU/GPU separation)
|
||||
|
||||
**Quick Wins (Low effort, high impact)**:
|
||||
- Database connection pooling (2-4 hours)
|
||||
- Index verification and optimization (1-2 hours)
|
||||
- Batch PDF rendering (4-6 hours)
|
||||
|
||||
**Long-term Improvements**:
|
||||
- OCR result caching with hashing
|
||||
- Generator patterns for streaming
|
||||
- Advanced profiling and monitoring
|
||||
|
||||
**Expected Impact**:
|
||||
- Connection pooling: 80-95% reduction in DB overhead
|
||||
- Indexes: 10-100x faster queries
|
||||
- Batch rendering: 50-90% less redundant work
|
||||
- **Overall**: 2-5x throughput improvement for batch processing
|
||||
1447
docs/REFACTORING_PLAN.md
Normal file
1447
docs/REFACTORING_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
170
docs/REFACTORING_SUMMARY.md
Normal file
170
docs/REFACTORING_SUMMARY.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 代码重构总结报告
|
||||
|
||||
## 📊 整体成果
|
||||
|
||||
### 测试状态
|
||||
- ✅ **688/688 测试全部通过** (100%)
|
||||
- ✅ **代码覆盖率**: 34% → 37% (+3%)
|
||||
- ✅ **0 个失败**, 0 个错误
|
||||
|
||||
### 测试覆盖率改进
|
||||
- ✅ **machine_code_parser**: 25% → 65% (+40%)
|
||||
- ✅ **新增测试**: 55个(633 → 688)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 已完成的重构
|
||||
|
||||
### 1. ✅ Matcher 模块化 (876行 → 205行, ↓76%)
|
||||
|
||||
**文件**:
|
||||
|
||||
**重构内容**:
|
||||
- 将单一876行文件拆分为 **11个模块**
|
||||
- 提取 **5种独立的匹配策略**
|
||||
- 创建专门的数据模型、工具函数和上下文处理模块
|
||||
|
||||
**新模块结构**:
|
||||
|
||||
|
||||
**测试结果**:
|
||||
- ✅ 77个 matcher 测试全部通过
|
||||
- ✅ 完整的README文档
|
||||
- ✅ 策略模式,易于扩展
|
||||
|
||||
**收益**:
|
||||
- 📉 代码量减少 76%
|
||||
- 📈 可维护性显著提高
|
||||
- ✨ 每个策略独立测试
|
||||
- 🔧 易于添加新策略
|
||||
|
||||
---
|
||||
|
||||
### 2. ✅ Machine Code Parser 轻度重构 + 测试覆盖 (919行 → 929行)
|
||||
|
||||
**文件**: src/ocr/machine_code_parser.py
|
||||
|
||||
**重构内容**:
|
||||
- 提取 **3个共享辅助方法**,消除重复代码
|
||||
- 优化上下文检测逻辑
|
||||
- 简化账号格式化方法
|
||||
|
||||
**测试改进**:
|
||||
- ✅ **新增55个测试**(24 → 79个)
|
||||
- ✅ **覆盖率**: 25% → 65% (+40%)
|
||||
- ✅ 所有688个项目测试通过
|
||||
|
||||
**新增测试覆盖**:
|
||||
- **第一轮** (22个测试):
|
||||
- `_detect_account_context()` - 8个测试(上下文检测)
|
||||
- `_normalize_account_spaces()` - 5个测试(空格规范化)
|
||||
- `_format_account()` - 4个测试(账号格式化)
|
||||
- `parse()` - 5个测试(主入口方法)
|
||||
- **第二轮** (33个测试):
|
||||
- `_extract_ocr()` - 8个测试(OCR 提取)
|
||||
- `_extract_bankgiro()` - 9个测试(Bankgiro 提取)
|
||||
- `_extract_plusgiro()` - 8个测试(Plusgiro 提取)
|
||||
- `_extract_amount()` - 8个测试(金额提取)
|
||||
|
||||
**收益**:
|
||||
- 🔄 消除80行重复代码
|
||||
- 📈 可测试性提高(可独立测试辅助方法)
|
||||
- 📖 代码可读性提升
|
||||
- ✅ 覆盖率从25%提升到65% (+40%)
|
||||
- 🎯 低风险,高回报
|
||||
|
||||
---
|
||||
|
||||
### 3. ✅ Field Extractor 分析 (决定不重构)
|
||||
|
||||
**文件**: (1183行)
|
||||
|
||||
**分析结果**: ❌ **不应重构**
|
||||
|
||||
**关键洞察**:
|
||||
- 表面相似的代码可能有**完全不同的用途**
|
||||
- field_extractor: **解析/提取** 字段值
|
||||
- src/normalize: **标准化/生成变体** 用于匹配
|
||||
- 两者职责不同,不应统一
|
||||
|
||||
**文档**:
|
||||
|
||||
---
|
||||
|
||||
## 📈 重构统计
|
||||
|
||||
### 代码行数变化
|
||||
|
||||
| 文件 | 重构前 | 重构后 | 变化 | 百分比 |
|
||||
|------|--------|--------|------|--------|
|
||||
| **matcher/field_matcher.py** | 876行 | 205行 | -671 | ↓76% |
|
||||
| **matcher/* (新增10个模块)** | 0行 | 466行 | +466 | 新增 |
|
||||
| **matcher 总计** | 876行 | 671行 | -205 | ↓23% |
|
||||
| **ocr/machine_code_parser.py** | 919行 | 929行 | +10 | +1% |
|
||||
| **总净减少** | - | - | **-195行** | **↓11%** |
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
| 模块 | 测试数 | 通过率 | 覆盖率 | 状态 |
|
||||
|------|--------|--------|--------|------|
|
||||
| matcher | 77 | 100% | - | ✅ |
|
||||
| field_extractor | 45 | 100% | 39% | ✅ |
|
||||
| machine_code_parser | 79 | 100% | 65% | ✅ |
|
||||
| normalizer | ~120 | 100% | - | ✅ |
|
||||
| 其他模块 | ~367 | 100% | - | ✅ |
|
||||
| **总计** | **688** | **100%** | **37%** | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 重构经验总结
|
||||
|
||||
### 成功经验
|
||||
|
||||
1. **✅ 先测试后重构**
|
||||
- 所有重构都有完整测试覆盖
|
||||
- 每次改动后立即验证测试
|
||||
- 100%测试通过率保证质量
|
||||
|
||||
2. **✅ 识别真正的重复**
|
||||
- 不是所有相似代码都是重复
|
||||
- field_extractor vs normalizer: 表面相似但用途不同
|
||||
- machine_code_parser: 真正的代码重复
|
||||
|
||||
3. **✅ 渐进式重构**
|
||||
- matcher: 大规模模块化 (策略模式)
|
||||
- machine_code_parser: 轻度重构 (提取共享方法)
|
||||
- field_extractor: 分析后决定不重构
|
||||
|
||||
### 关键决策
|
||||
|
||||
#### ✅ 应该重构的情况
|
||||
- **matcher**: 单一文件过长 (876行),包含多种策略
|
||||
- **machine_code_parser**: 多处相同用途的重复代码
|
||||
|
||||
#### ❌ 不应重构的情况
|
||||
- **field_extractor**: 相似代码有不同用途
|
||||
|
||||
### 教训
|
||||
|
||||
**不要盲目追求DRY原则**
|
||||
> 相似代码不一定是重复。要理解代码的**真实用途**。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**关键成果**:
|
||||
- 📉 净减少 195 行代码
|
||||
- 📈 代码覆盖率 +3% (34% → 37%)
|
||||
- ✅ 测试数量 +55 (633 → 688)
|
||||
- 🎯 machine_code_parser 覆盖率 +40% (25% → 65%)
|
||||
- ✨ 模块化程度显著提高
|
||||
- 🎯 可维护性大幅提升
|
||||
|
||||
**重要教训**:
|
||||
> 相似的代码不一定是重复的代码。理解代码的真实用途,才能做出正确的重构决策。
|
||||
|
||||
**下一步建议**:
|
||||
1. 继续提升 machine_code_parser 覆盖率到 80%+ (目前 65%)
|
||||
2. 为其他低覆盖模块添加测试(field_extractor 39%, pipeline 19%)
|
||||
3. 完善边界条件和异常情况的测试
|
||||
258
docs/TEST_COVERAGE_IMPROVEMENT.md
Normal file
258
docs/TEST_COVERAGE_IMPROVEMENT.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 测试覆盖率改进报告
|
||||
|
||||
## 📊 改进概览
|
||||
|
||||
### 整体统计
|
||||
- ✅ **测试总数**: 633 → 688 (+55个测试, +8.7%)
|
||||
- ✅ **通过率**: 100% (688/688)
|
||||
- ✅ **整体覆盖率**: 34% → 37% (+3%)
|
||||
|
||||
### machine_code_parser.py 专项改进
|
||||
- ✅ **测试数**: 24 → 79 (+55个测试, +229%)
|
||||
- ✅ **覆盖率**: 25% → 65% (+40%)
|
||||
- ✅ **未覆盖行**: 273 → 129 (减少144行)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 新增测试详情
|
||||
|
||||
### 第一轮改进 (22个测试)
|
||||
|
||||
#### 1. TestDetectAccountContext (8个测试)
|
||||
|
||||
测试新增的 `_detect_account_context()` 辅助方法。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_bankgiro_keyword` - 检测 'bankgiro' 关键词
|
||||
2. `test_bg_keyword` - 检测 'bg:' 缩写
|
||||
3. `test_plusgiro_keyword` - 检测 'plusgiro' 关键词
|
||||
4. `test_postgiro_keyword` - 检测 'postgiro' 别名
|
||||
5. `test_pg_keyword` - 检测 'pg:' 缩写
|
||||
6. `test_both_contexts` - 同时存在两种关键词
|
||||
7. `test_no_context` - 无账号关键词
|
||||
8. `test_case_insensitive` - 大小写不敏感检测
|
||||
|
||||
**覆盖的代码路径**:
|
||||
```python
|
||||
def _detect_account_context(self, tokens: list[TextToken]) -> dict[str, bool]:
|
||||
context_text = ' '.join(t.text.lower() for t in tokens)
|
||||
return {
|
||||
'bankgiro': any(kw in context_text for kw in ['bankgiro', 'bg:', 'bg ']),
|
||||
'plusgiro': any(kw in context_text for kw in ['plusgiro', 'postgiro', 'plusgirokonto', 'pg:', 'pg ']),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. TestNormalizeAccountSpacesMethod (5个测试)
|
||||
|
||||
测试新增的 `_normalize_account_spaces()` 辅助方法。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_removes_spaces_after_arrow` - 移除 > 后的空格
|
||||
2. `test_multiple_consecutive_spaces` - 处理多个连续空格
|
||||
3. `test_no_arrow_returns_unchanged` - 无 > 标记时返回原值
|
||||
4. `test_spaces_before_arrow_preserved` - 保留 > 前的空格
|
||||
5. `test_empty_string` - 空字符串处理
|
||||
|
||||
**覆盖的代码路径**:
|
||||
```python
|
||||
def _normalize_account_spaces(self, line: str) -> str:
|
||||
if '>' not in line:
|
||||
return line
|
||||
parts = line.split('>', 1)
|
||||
after_arrow = parts[1]
|
||||
normalized = re.sub(r'(\d)\s+(\d)', r'\1\2', after_arrow)
|
||||
while re.search(r'(\d)\s+(\d)', normalized):
|
||||
normalized = re.sub(r'(\d)\s+(\d)', r'\1\2', normalized)
|
||||
return parts[0] + '>' + normalized
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. TestFormatAccount (4个测试)
|
||||
|
||||
测试新增的 `_format_account()` 辅助方法。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_plusgiro_context_forces_plusgiro` - Plusgiro 上下文强制格式化为 Plusgiro
|
||||
2. `test_valid_bankgiro_7_digits` - 7位有效 Bankgiro 格式化
|
||||
3. `test_valid_bankgiro_8_digits` - 8位有效 Bankgiro 格式化
|
||||
4. `test_defaults_to_bankgiro_when_ambiguous` - 模糊情况默认 Bankgiro
|
||||
|
||||
**覆盖的代码路径**:
|
||||
```python
|
||||
def _format_account(self, account_digits: str, is_plusgiro_context: bool) -> tuple[str, str]:
|
||||
if is_plusgiro_context:
|
||||
formatted = f"{account_digits[:-1]}-{account_digits[-1]}"
|
||||
return formatted, 'plusgiro'
|
||||
|
||||
# Luhn 验证逻辑
|
||||
pg_valid = FieldValidators.is_valid_plusgiro(account_digits)
|
||||
bg_valid = FieldValidators.is_valid_bankgiro(account_digits)
|
||||
|
||||
# 决策逻辑
|
||||
if pg_valid and not bg_valid:
|
||||
return pg_formatted, 'plusgiro'
|
||||
elif bg_valid and not pg_valid:
|
||||
return bg_formatted, 'bankgiro'
|
||||
else:
|
||||
return bg_formatted, 'bankgiro'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. TestParseMethod (5个测试)
|
||||
|
||||
测试主入口 `parse()` 方法。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_parse_empty_tokens` - 空 token 列表处理
|
||||
2. `test_parse_finds_payment_line_in_bottom_region` - 在页面底部35%区域查找付款行
|
||||
3. `test_parse_ignores_top_region` - 忽略页面顶部区域
|
||||
4. `test_parse_with_context_keywords` - 检测上下文关键词
|
||||
5. `test_parse_stores_source_tokens` - 存储源 token
|
||||
|
||||
**覆盖的代码路径**:
|
||||
- Token 过滤(底部区域检测)
|
||||
- 上下文关键词检测
|
||||
- 付款行查找和解析
|
||||
- 结果对象构建
|
||||
|
||||
---
|
||||
|
||||
### 第二轮改进 (33个测试)
|
||||
|
||||
#### 5. TestExtractOCR (8个测试)
|
||||
|
||||
测试 `_extract_ocr()` 方法 - OCR 参考号码提取。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_extract_valid_ocr_10_digits` - 提取10位 OCR 号码
|
||||
2. `test_extract_valid_ocr_15_digits` - 提取15位 OCR 号码
|
||||
3. `test_extract_ocr_with_hash_markers` - 带 # 标记的 OCR
|
||||
4. `test_extract_longest_ocr_when_multiple` - 多个候选时选最长
|
||||
5. `test_extract_ocr_ignores_short_numbers` - 忽略短于10位的数字
|
||||
6. `test_extract_ocr_ignores_long_numbers` - 忽略长于25位的数字
|
||||
7. `test_extract_ocr_excludes_bankgiro_variants` - 排除 Bankgiro 变体
|
||||
8. `test_extract_ocr_empty_tokens` - 空 token 处理
|
||||
|
||||
#### 6. TestExtractBankgiro (9个测试)
|
||||
|
||||
测试 `_extract_bankgiro()` 方法 - Bankgiro 账号提取。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_extract_bankgiro_7_digits_with_dash` - 带破折号的7位 Bankgiro
|
||||
2. `test_extract_bankgiro_7_digits_without_dash` - 无破折号的7位 Bankgiro
|
||||
3. `test_extract_bankgiro_8_digits_with_dash` - 带破折号的8位 Bankgiro
|
||||
4. `test_extract_bankgiro_8_digits_without_dash` - 无破折号的8位 Bankgiro
|
||||
5. `test_extract_bankgiro_with_spaces` - 带空格的 Bankgiro
|
||||
6. `test_extract_bankgiro_handles_plusgiro_format` - 处理 Plusgiro 格式
|
||||
7. `test_extract_bankgiro_with_context` - 带上下文关键词
|
||||
8. `test_extract_bankgiro_ignores_plusgiro_context` - 忽略 Plusgiro 上下文
|
||||
9. `test_extract_bankgiro_empty_tokens` - 空 token 处理
|
||||
|
||||
#### 7. TestExtractPlusgiro (8个测试)
|
||||
|
||||
测试 `_extract_plusgiro()` 方法 - Plusgiro 账号提取。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_extract_plusgiro_7_digits_with_dash` - 带破折号的7位 Plusgiro
|
||||
2. `test_extract_plusgiro_7_digits_without_dash` - 无破折号的7位 Plusgiro
|
||||
3. `test_extract_plusgiro_8_digits` - 8位 Plusgiro
|
||||
4. `test_extract_plusgiro_with_spaces` - 带空格的 Plusgiro
|
||||
5. `test_extract_plusgiro_with_context` - 带上下文关键词
|
||||
6. `test_extract_plusgiro_ignores_too_short` - 忽略少于7位
|
||||
7. `test_extract_plusgiro_ignores_too_long` - 忽略多于8位
|
||||
8. `test_extract_plusgiro_empty_tokens` - 空 token 处理
|
||||
|
||||
#### 8. TestExtractAmount (8个测试)
|
||||
|
||||
测试 `_extract_amount()` 方法 - 金额提取。
|
||||
|
||||
**测试用例**:
|
||||
1. `test_extract_amount_with_comma_decimal` - 逗号小数分隔符
|
||||
2. `test_extract_amount_with_dot_decimal` - 点号小数分隔符
|
||||
3. `test_extract_amount_integer` - 整数金额
|
||||
4. `test_extract_amount_with_thousand_separator` - 千位分隔符
|
||||
5. `test_extract_amount_large_number` - 大额金额
|
||||
6. `test_extract_amount_ignores_too_large` - 忽略过大金额
|
||||
7. `test_extract_amount_ignores_zero` - 忽略零或负数
|
||||
8. `test_extract_amount_empty_tokens` - 空 token 处理
|
||||
|
||||
---
|
||||
|
||||
## 📈 覆盖率分析
|
||||
|
||||
### 已覆盖的方法
|
||||
✅ `_detect_account_context()` - **100%** (第一轮新增)
|
||||
✅ `_normalize_account_spaces()` - **100%** (第一轮新增)
|
||||
✅ `_format_account()` - **95%** (第一轮新增)
|
||||
✅ `parse()` - **70%** (第一轮改进)
|
||||
✅ `_parse_standard_payment_line()` - **95%** (已有测试)
|
||||
✅ `_extract_ocr()` - **85%** (第二轮新增)
|
||||
✅ `_extract_bankgiro()` - **90%** (第二轮新增)
|
||||
✅ `_extract_plusgiro()` - **90%** (第二轮新增)
|
||||
✅ `_extract_amount()` - **80%** (第二轮新增)
|
||||
|
||||
### 仍需改进的方法 (未覆盖/部分覆盖)
|
||||
⚠️ `_calculate_confidence()` - **0%** (未测试)
|
||||
⚠️ `cross_validate()` - **0%** (未测试)
|
||||
⚠️ `get_region_bbox()` - **0%** (未测试)
|
||||
⚠️ `_find_tokens_with_values()` - **部分覆盖**
|
||||
⚠️ `_find_machine_code_line_tokens()` - **部分覆盖**
|
||||
|
||||
### 未覆盖的代码行(129行)
|
||||
主要集中在:
|
||||
1. **验证方法** (lines 805-824): `_calculate_confidence`, `cross_validate`
|
||||
2. **辅助方法** (lines 80-92, 336-369, 377-407): Token 查找、bbox 计算、日志记录
|
||||
3. **边界条件** (lines 648-653, 690, 699, 759-760等): 某些提取方法的边界情况
|
||||
|
||||
---
|
||||
|
||||
## 🎯 改进建议
|
||||
|
||||
### ✅ 已完成目标
|
||||
- ✅ 覆盖率从 25% 提升到 65% (+40%)
|
||||
- ✅ 测试数量从 24 增加到 79 (+55个)
|
||||
- ✅ 提取方法全部测试(_extract_ocr, _extract_bankgiro, _extract_plusgiro, _extract_amount)
|
||||
|
||||
### 下一步目标(覆盖率 65% → 80%+)
|
||||
1. **添加验证方法测试** - 为 `_calculate_confidence`, `cross_validate` 添加测试
|
||||
2. **添加辅助方法测试** - 为 token 查找和 bbox 计算方法添加测试
|
||||
3. **完善边界条件** - 增加边界情况和异常处理的测试
|
||||
4. **集成测试** - 添加端到端的集成测试,使用真实 PDF token 数据
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的改进
|
||||
|
||||
### 重构收益
|
||||
- ✅ 提取的3个辅助方法现在可以独立测试
|
||||
- ✅ 测试粒度更细,更容易定位问题
|
||||
- ✅ 代码可读性提高,测试用例清晰易懂
|
||||
|
||||
### 质量保证
|
||||
- ✅ 所有655个测试100%通过
|
||||
- ✅ 无回归问题
|
||||
- ✅ 新增测试覆盖了之前未测试的重构代码
|
||||
|
||||
---
|
||||
|
||||
## 📚 测试编写经验
|
||||
|
||||
### 成功经验
|
||||
1. **使用 fixture 创建测试数据** - `_create_token()` 辅助方法简化了 token 创建
|
||||
2. **按方法组织测试类** - 每个方法一个测试类,结构清晰
|
||||
3. **测试用例命名清晰** - `test_<what>_<condition>` 格式,一目了然
|
||||
4. **覆盖关键路径** - 优先测试常见场景和边界条件
|
||||
|
||||
### 遇到的问题
|
||||
1. **Token 初始化参数** - 忘记了 `page_no` 参数,导致初始测试失败
|
||||
- 解决:修复 `_create_token()` 辅助方法,添加 `page_no=0`
|
||||
|
||||
---
|
||||
|
||||
**报告日期**: 2026-01-24
|
||||
**状态**: ✅ 完成
|
||||
**下一步**: 继续提升覆盖率到 60%+
|
||||
Reference in New Issue
Block a user