feat: surface ocr review quality

This commit is contained in:
Codex
2026-05-04 11:36:10 +08:00
parent e0a3393b46
commit 6e1ab8658e
7 changed files with 211 additions and 4 deletions
+50 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Callable, Dict, List, Optional
import httpx
from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageFont
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
@@ -133,6 +133,44 @@ def _make_image_contract(low_res: bool) -> bytes:
return pdf_buffer.getvalue()
def _load_cjk_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
font_candidates = [
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simsun.ttc"),
Path("C:/Windows/Fonts/simhei.ttf"),
]
for font_path in font_candidates:
if font_path.exists():
try:
return ImageFont.truetype(str(font_path), size=size)
except OSError:
continue
return ImageFont.load_default()
def make_cn_image_contract() -> bytes:
size = (900, 560)
image = Image.new("RGB", size, "white")
draw = ImageDraw.Draw(image)
font = _load_cjk_font(30)
lines = [
"采购合同扫描样本",
"甲方应在验收合格并收到发票后十五个工作日内付款。",
"乙方应对商业秘密和技术资料承担保密义务。",
"如乙方延期交付,双方应按约承担违约责任。",
]
y = 48
for line in lines:
draw.text((40, y), line, fill="black", font=font)
y += 86
pdf_buffer = io.BytesIO()
pdf = canvas.Canvas(pdf_buffer)
pdf.drawImage(ImageReader(image), 36, 280, width=540, height=340)
pdf.save()
return pdf_buffer.getvalue()
def make_image_only_pdf() -> bytes:
return _make_image_contract(low_res=False)
@@ -211,6 +249,17 @@ def build_cases(sample_contract_path: Optional[str]) -> List[PdfCase]:
require_message_contains="OCR 识别质量不足",
notes="OCR 第一批实现后,这类样本允许 WARN 或 BLOCK,但不能伪装成高质量 PASS。",
),
PdfCase(
case_id="C-003",
category="C",
label="中文扫描合同 PDF(OCR第一批)",
expectation="ocr_supported",
file_name="gate-cn-image.pdf",
build_bytes=make_cn_image_contract,
allow_ocr_statuses=["PASS", "WARN"],
require_preview=["采购合同扫描样本", "甲方应在验收合格并收到发票后十五个工作日内付款。"],
notes="OCR 第一批实现后,中文扫描合同应至少进入正式审查,并暴露 OCR 门禁结果。",
),
]
if sample_contract_path:
+38
View File
@@ -2168,6 +2168,43 @@ a {
border: 1px solid rgba(22, 119, 255, 0.12);
}
.review-detail-ocr-panel {
margin-top: 18px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(248, 250, 252, 0.92);
display: flex;
flex-direction: column;
gap: 12px;
}
.review-detail-ocr-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.review-detail-ocr-card {
padding: 12px 14px;
border-radius: 14px;
background: #fff;
border: 1px solid rgba(148, 163, 184, 0.14);
display: flex;
flex-direction: column;
gap: 6px;
}
.review-detail-ocr-card span {
color: #64748b;
font-size: 12px;
}
.review-detail-ocr-card strong {
color: #0f172a;
font-size: 15px;
}
.review-detail-download {
margin-top: 12px;
}
@@ -4046,6 +4083,7 @@ body::selection {
.review-detail-hero-stats,
.review-detail-decision-strip,
.review-detail-group-grid,
.review-detail-ocr-grid,
.chat-review-meta-grid,
.chat-answer-summary-grid,
.chat-action-card-grid,
+74 -1
View File
@@ -111,6 +111,43 @@ function formatSourcePreview(value: string | null | undefined) {
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
}
function formatDocumentKind(value: string | null | undefined) {
if (!value) return "-";
const kindMap: Record<string, string> = {
text_pdf: "文本型 PDF",
scanned_pdf: "扫描件 PDF",
mixed_pdf: "混合型 PDF",
unknown_pdf: "未知 PDF",
platform_review_report_pdf: "平台审查报告 PDF",
};
return kindMap[value] ?? value;
}
function formatExtractionMode(value: string | null | undefined) {
if (!value) return "-";
const modeMap: Record<string, string> = {
text: "文本抽取",
ocr: "OCR 抽取",
reject_report_pdf: "报告型 PDF 拦截",
ocr_disabled: "OCR 未启用",
ocr_runtime_missing: "OCR 运行时缺失",
ocr_parse_failed: "OCR 前解析失败",
};
return modeMap[value] ?? value;
}
function formatOcrGateStatus(value: string | null | undefined) {
if (!value) return "-";
return value;
}
function getOcrGateColor(value: string | null | undefined) {
if (value === "PASS") return "green";
if (value === "WARN") return "gold";
if (value === "BLOCK") return "red";
return "default";
}
function formatClauseContent(value: string | undefined) {
if (!value) return "暂无条款内容";
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
@@ -336,6 +373,8 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
const overallRisk = formatRisk(result?.risk_summary.overall);
const reviewOpinion = result?.review_opinion ?? "-";
const recommendation = formatRecommendation(result?.recommendation);
const documentAnalysis = result?.confidence_summary?.document_analysis ?? null;
const ocrQuality = documentAnalysis?.ocr_quality ?? null;
const detailAnchors = [
{ href: "#review-risk-overview", label: "风险总览" },
@@ -644,7 +683,6 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
<Paragraph className="review-detail-download">
审查报告:
<TypographyLink
href={report.download_url}
onClick={(event) => {
event.preventDefault();
void handleDownloadReport();
@@ -708,6 +746,41 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
{detail?.report_status === "partial" ? <Tag color="orange">报告部分完成</Tag> : null}
{detail?.can_retry ? <Tag color="orange">可重试</Tag> : null}
</Space>
{documentAnalysis ? (
<section className="review-detail-ocr-panel">
<span className="landing-card-eyebrow">OCR Quality Gate</span>
<strong>扫描件识别与质量门禁</strong>
<div className="review-detail-ocr-grid">
<div className="review-detail-ocr-card">
<span>文档类型</span>
<strong>{formatDocumentKind(documentAnalysis.document_kind)}</strong>
</div>
<div className="review-detail-ocr-card">
<span>抽取模式</span>
<strong>{formatExtractionMode(documentAnalysis.extraction_mode)}</strong>
</div>
<div className="review-detail-ocr-card">
<span>OCR 门禁</span>
<Tag color={getOcrGateColor(documentAnalysis.ocr_gate_status)}>{formatOcrGateStatus(documentAnalysis.ocr_gate_status)}</Tag>
</div>
<div className="review-detail-ocr-card">
<span>平均置信度</span>
<strong>{ocrQuality?.mean_confidence == null ? "-" : `${ocrQuality.mean_confidence}`}</strong>
</div>
<div className="review-detail-ocr-card">
<span>识别字符数</span>
<strong>{ocrQuality?.char_count ?? "-"}</strong>
</div>
<div className="review-detail-ocr-card">
<span>噪声比例</span>
<strong>{ocrQuality?.noise_ratio == null ? "-" : `${ocrQuality.noise_ratio}`}</strong>
</div>
</div>
{ocrQuality?.failure_message ? (
<div className="review-detail-info-note">OCR 提示:{ocrQuality.failure_message}</div>
) : null}
</section>
) : null}
<Descriptions column={3} size="small" style={{ marginTop: 16 }}>
<Descriptions.Item label="文本解析">{formatLifecycleStatus(detail?.parse_status)}</Descriptions.Item>
<Descriptions.Item label="规则审查">{formatLifecycleStatus(detail?.review_status)}</Descriptions.Item>
+23 -1
View File
@@ -71,6 +71,26 @@ export type ReviewResult = {
}>;
review_opinion: string;
recommendation: string;
confidence_summary?: {
analysis_mode?: string;
provider_mode?: string;
router_status?: string;
document_analysis?: {
document_kind?: string;
extraction_mode?: string;
ocr_gate_status?: string;
ocr_quality?: {
status?: string;
char_count?: number;
token_count?: number;
line_count?: number;
printable_ratio?: number;
noise_ratio?: number;
mean_confidence?: number | null;
failure_message?: string | null;
};
};
};
};
export type ReviewReport = {
@@ -80,7 +100,9 @@ export type ReviewReport = {
};
export async function createReview(formData: FormData) {
const response = await http.post<ApiResponse<ReviewCreateResponse>>("/reviews", formData);
const response = await http.post<ApiResponse<ReviewCreateResponse>>("/reviews", formData, {
timeout: 180000,
});
return response.data;
}
@@ -83,12 +83,29 @@
- 当前仍由 OCR 质量门禁拦截
- 属于受控失败,不再伪成功
3. `C-003` 中文扫描合同 PDF
- 已升级为可正式支持
- 当前可达到 `WARN`
4. **前端显示 OCR 门禁状态**
- 审查详情页已显示:
- 文档类型
- 抽取模式
- OCR 门禁状态
- 平均置信度
- 识别字符数
- 噪声比例
5. **中文扫描合同样本库清单**
- 已新增:
- `project-docs/中文扫描合同样本库清单-2026-05-04.csv`
## 4. 未在第一批实现的内容
1. 扫描件中文复杂版式专项优化
2. 表格型扫描 PDF 专项优化
3. 多页扫描件跨页段落拼接优化
4. OCR 结果前端可视化展示
4. OCR 结果前端更多场景可视化展示
5. OCR 失败原因的人机交互优化
## 5. 当前验收口径
@@ -226,6 +226,7 @@ OCR上线后验收标准:
- `C-001` 图片型 PDF(OCR第一批)
- `C-002` 低清晰度图片型 PDF(OCR第一批)
- `C-003` 中文扫描合同 PDF(OCR第一批)
当前门禁判定规则:
@@ -233,5 +234,6 @@ OCR上线后验收标准:
2. B类全部必须被正确拦截
3. `C-001` 这类清晰图片型 PDF 在 OCR 第一批中应至少达到 `PASS/WARN`
4. `C-002` 这类低清晰度图片型 PDF 在 OCR 第一批中允许 `WARN/BLOCK`,但不能伪装成高质量 `PASS`
5. `C-003` 这类中文扫描合同 PDF 在 OCR 第一批中应至少达到 `PASS/WARN`
如果未来 OCR 继续增强,应继续在这支门禁里把更多 C类样本逐个升级为 `PASS / WARN / BLOCK` 判定。
@@ -0,0 +1,6 @@
sample_id,title,document_type,language,current_stage,current_expectation,gate_case,source_mode,notes
C-003,中文扫描采购合同(清晰),scanned_pdf,zh-CN,ocr_first_batch,PASS/WARN,C-003,dynamic_generated,当前已接入 PDF 验收门禁;用于验证中文 OCR 进入正式审查链路
C-004,中文扫描采购合同(低清晰度),scanned_pdf,zh-CN,planned_second_batch,WARN/BLOCK,,planned_fixture,下一批用于验证中文低质量扫描件的 OCR 质量门禁
C-005,中文扫描服务合同(多页),scanned_pdf,zh-CN,planned_second_batch,PASS/WARN,,planned_fixture,下一批用于验证多页扫描件、跨页拼接与页眉页脚清洗
C-006,中文扫描劳动合同(盖章页),scanned_pdf,zh-CN,planned_second_batch,PASS/WARN,,planned_fixture,下一批用于验证盖章、签章页与正文分离
C-007,中文扫描框架协议(表格较多),scanned_pdf,zh-CN,planned_second_batch,WARN/BLOCK,,planned_fixture,下一批用于验证表格型扫描 PDF 的 OCR 与后清洗
1 sample_id title document_type language current_stage current_expectation gate_case source_mode notes
2 C-003 中文扫描采购合同(清晰) scanned_pdf zh-CN ocr_first_batch PASS/WARN C-003 dynamic_generated 当前已接入 PDF 验收门禁;用于验证中文 OCR 进入正式审查链路
3 C-004 中文扫描采购合同(低清晰度) scanned_pdf zh-CN planned_second_batch WARN/BLOCK planned_fixture 下一批用于验证中文低质量扫描件的 OCR 质量门禁
4 C-005 中文扫描服务合同(多页) scanned_pdf zh-CN planned_second_batch PASS/WARN planned_fixture 下一批用于验证多页扫描件、跨页拼接与页眉页脚清洗
5 C-006 中文扫描劳动合同(盖章页) scanned_pdf zh-CN planned_second_batch PASS/WARN planned_fixture 下一批用于验证盖章、签章页与正文分离
6 C-007 中文扫描框架协议(表格较多) scanned_pdf zh-CN planned_second_batch WARN/BLOCK planned_fixture 下一批用于验证表格型扫描 PDF 的 OCR 与后清洗