feat: surface ocr review quality
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 与后清洗
|
||||
|
Reference in New Issue
Block a user