mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(pdf,chat): fix OCR trigger misjudgment, false success, and duplicate image injection
This commit is contained in:
parent
d8cf5acf0f
commit
7bcdbf4523
@ -153,6 +153,10 @@ public class DocumentExtractTool {
|
|||||||
// ==================== PDF 提取链 ====================
|
// ==================== PDF 提取链 ====================
|
||||||
|
|
||||||
private ExtractedContent extractPdf(Path path, String options, List<String> attempts) throws Exception {
|
private ExtractedContent extractPdf(Path path, String options, List<String> attempts) throws Exception {
|
||||||
|
// 先获取 PDF 真实页数(不依赖提取出的文本长度反推)
|
||||||
|
int realPageCount = getPdfPageCount(path);
|
||||||
|
log.info("[DocumentExtract] PDF 真实页数: {} ({})", realPageCount, path.getFileName());
|
||||||
|
|
||||||
String bestContent = null;
|
String bestContent = null;
|
||||||
String bestMethod = null;
|
String bestMethod = null;
|
||||||
|
|
||||||
@ -160,12 +164,13 @@ public class DocumentExtractTool {
|
|||||||
long t0 = System.currentTimeMillis();
|
long t0 = System.currentTimeMillis();
|
||||||
String content = tryPdftotext(path, options);
|
String content = tryPdftotext(path, options);
|
||||||
if (content != null && !content.isBlank()) {
|
if (content != null && !content.isBlank()) {
|
||||||
int pages = estimatePages(content);
|
if (!needsOcr(content, realPageCount)) {
|
||||||
if (!needsOcr(content, pages)) {
|
|
||||||
attempts.add("pdftotext: 成功 (" + (System.currentTimeMillis() - t0) + "ms)");
|
attempts.add("pdftotext: 成功 (" + (System.currentTimeMillis() - t0) + "ms)");
|
||||||
return new ExtractedContent(content, "pdftotext", pages);
|
return new ExtractedContent(content, "pdftotext", realPageCount > 0 ? realPageCount : estimatePages(content));
|
||||||
}
|
}
|
||||||
attempts.add("pdftotext: 文本过少 (每页 " + charsPerPage(content, pages) + " 字符),可能是扫描版");
|
double perPage = realPageCount > 0 ? (double) content.strip().length() / realPageCount : 0;
|
||||||
|
attempts.add("pdftotext: 文本过少 (总 " + content.strip().length() + " 字符, "
|
||||||
|
+ realPageCount + " 页, 每页 " + String.format("%.0f", perPage) + " 字符),可能是扫描版");
|
||||||
bestContent = content;
|
bestContent = content;
|
||||||
bestMethod = "pdftotext";
|
bestMethod = "pdftotext";
|
||||||
} else {
|
} else {
|
||||||
@ -176,10 +181,9 @@ public class DocumentExtractTool {
|
|||||||
long t1 = System.currentTimeMillis();
|
long t1 = System.currentTimeMillis();
|
||||||
content = tryPythonPdfExtractor(path, options);
|
content = tryPythonPdfExtractor(path, options);
|
||||||
if (content != null && !content.isBlank()) {
|
if (content != null && !content.isBlank()) {
|
||||||
int pages = estimatePages(content);
|
if (!needsOcr(content, realPageCount)) {
|
||||||
if (!needsOcr(content, pages)) {
|
|
||||||
attempts.add("python_pdf: 成功 (" + (System.currentTimeMillis() - t1) + "ms)");
|
attempts.add("python_pdf: 成功 (" + (System.currentTimeMillis() - t1) + "ms)");
|
||||||
return new ExtractedContent(content, "python_pdfplumber", pages);
|
return new ExtractedContent(content, "python_pdfplumber", realPageCount > 0 ? realPageCount : estimatePages(content));
|
||||||
}
|
}
|
||||||
attempts.add("python_pdf: 文本过少");
|
attempts.add("python_pdf: 文本过少");
|
||||||
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
|
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
|
||||||
@ -194,10 +198,9 @@ public class DocumentExtractTool {
|
|||||||
long t2 = System.currentTimeMillis();
|
long t2 = System.currentTimeMillis();
|
||||||
content = extractPdfWithJava(path);
|
content = extractPdfWithJava(path);
|
||||||
if (content != null && !content.isBlank()) {
|
if (content != null && !content.isBlank()) {
|
||||||
int pages = estimatePages(content);
|
if (!needsOcr(content, realPageCount)) {
|
||||||
if (!needsOcr(content, pages)) {
|
|
||||||
attempts.add("java_pdf: 成功 (" + (System.currentTimeMillis() - t2) + "ms)");
|
attempts.add("java_pdf: 成功 (" + (System.currentTimeMillis() - t2) + "ms)");
|
||||||
return new ExtractedContent(content, "java_pdfbox", pages);
|
return new ExtractedContent(content, "java_pdfbox", realPageCount > 0 ? realPageCount : estimatePages(content));
|
||||||
}
|
}
|
||||||
attempts.add("java_pdf: 文本过少");
|
attempts.add("java_pdf: 文本过少");
|
||||||
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
|
if (bestContent == null || content.strip().length() > bestContent.strip().length()) {
|
||||||
@ -211,10 +214,11 @@ public class DocumentExtractTool {
|
|||||||
// 4. OCR fallback(扫描版/照片型 PDF)
|
// 4. OCR fallback(扫描版/照片型 PDF)
|
||||||
log.info("[DocumentExtract] 文本提取不足,尝试 OCR: {}", path.getFileName());
|
log.info("[DocumentExtract] 文本提取不足,尝试 OCR: {}", path.getFileName());
|
||||||
long t3 = System.currentTimeMillis();
|
long t3 = System.currentTimeMillis();
|
||||||
content = tryOcrExtract(path, attempts);
|
OcrResult ocrResult = tryOcrExtract(path, attempts);
|
||||||
if (content != null && !content.isBlank()) {
|
if (ocrResult != null && ocrResult.text != null && !ocrResult.text.isBlank()) {
|
||||||
attempts.add("ocr_tesseract: 成功 (" + (System.currentTimeMillis() - t3) + "ms)");
|
attempts.add("ocr_tesseract: 成功 (" + (System.currentTimeMillis() - t3) + "ms, "
|
||||||
return new ExtractedContent(content, "ocr_tesseract", estimatePages(content));
|
+ ocrResult.successPages + "/" + ocrResult.totalPages + " 页识别成功)");
|
||||||
|
return new ExtractedContent(ocrResult.text, "ocr_tesseract", ocrResult.totalPages);
|
||||||
}
|
}
|
||||||
// attempts 已由 tryOcrExtract 内部记录失败原因
|
// attempts 已由 tryOcrExtract 内部记录失败原因
|
||||||
|
|
||||||
@ -222,34 +226,72 @@ public class DocumentExtractTool {
|
|||||||
if (bestContent != null) {
|
if (bestContent != null) {
|
||||||
log.warn("[DocumentExtract] OCR 不可用,返回部分文本结果: method={}, length={}",
|
log.warn("[DocumentExtract] OCR 不可用,返回部分文本结果: method={}, length={}",
|
||||||
bestMethod, bestContent.strip().length());
|
bestMethod, bestContent.strip().length());
|
||||||
return new ExtractedContent(bestContent, bestMethod + "_partial", estimatePages(bestContent));
|
int pages = realPageCount > 0 ? realPageCount : estimatePages(bestContent);
|
||||||
|
return new ExtractedContent(bestContent, bestMethod + "_partial", pages);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Exception("所有 PDF 提取方法都失败(包括 OCR)");
|
throw new Exception("所有 PDF 提取方法都失败(包括 OCR)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断提取到的文本是否太少、需要尝试 OCR。
|
* 获取 PDF 真实页数。优先用 pdfinfo(Poppler 自带),失败则用 Python pypdf。
|
||||||
* 基于字符密度(每页平均字符数)而非总字符数,避免误判短票据/证书类 PDF。
|
* 不依赖提取出的文本长度反推,避免扫描版 PDF 误判页数。
|
||||||
*/
|
*/
|
||||||
private boolean needsOcr(String text, int estimatedPages) {
|
private int getPdfPageCount(Path path) {
|
||||||
|
// 方法 1: pdfinfo(Poppler 附带工具)
|
||||||
|
try {
|
||||||
|
String output = executeCommand(List.of("pdfinfo", path.toString()));
|
||||||
|
if (output != null) {
|
||||||
|
for (String line : output.split("\n")) {
|
||||||
|
if (line.toLowerCase().startsWith("pages:")) {
|
||||||
|
String num = line.substring(6).strip();
|
||||||
|
return Integer.parseInt(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[DocumentExtract] pdfinfo 获取页数失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
// 方法 2: Python pypdf
|
||||||
|
try {
|
||||||
|
String script = "import sys; from pypdf import PdfReader; print(len(PdfReader(sys.argv[1]).pages))";
|
||||||
|
String result = tryPythonScript(script, path.toString());
|
||||||
|
if (result != null && !result.isBlank()) {
|
||||||
|
return Integer.parseInt(result.strip());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[DocumentExtract] pypdf 获取页数失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return 0; // 未知页数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断提取到的文本是否太少、需要尝试 OCR。
|
||||||
|
* 使用真实页数(来自 getPdfPageCount)计算字符密度,不再依赖 estimatePages 反推。
|
||||||
|
* 页数未知(0)时,只看总字符数。
|
||||||
|
*/
|
||||||
|
private boolean needsOcr(String text, int realPageCount) {
|
||||||
if (text == null || text.isBlank()) return true;
|
if (text == null || text.isBlank()) return true;
|
||||||
String stripped = text.strip();
|
String stripped = text.strip();
|
||||||
if (stripped.length() < 20) return true;
|
if (stripped.length() < 20) return true;
|
||||||
double perPage = charsPerPage(stripped, estimatedPages);
|
if (realPageCount <= 0) {
|
||||||
|
// 页数未知时回退到总字符数判定(保守阈值)
|
||||||
|
return stripped.length() < 100;
|
||||||
|
}
|
||||||
|
double perPage = (double) stripped.length() / realPageCount;
|
||||||
// 正常文本 PDF 每页至少数百字符;每页不到 30 字符大概率是扫描版
|
// 正常文本 PDF 每页至少数百字符;每页不到 30 字符大概率是扫描版
|
||||||
return perPage < 30;
|
return perPage < 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double charsPerPage(String text, int pages) {
|
/** OCR 结果(含成功/失败页数统计) */
|
||||||
return (double) text.strip().length() / Math.max(1, pages);
|
private record OcrResult(String text, int totalPages, int successPages) {}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCR 提取:pdftoppm 转图片 + tesseract 识别文字。
|
* OCR 提取:pdftoppm 转图片 + tesseract 识别文字。
|
||||||
* 仅依赖 Poppler(pdftoppm)和 tesseract 系统命令,不引入新 Python 依赖。
|
* 仅依赖 Poppler(pdftoppm)和 tesseract 系统命令,不引入新 Python 依赖。
|
||||||
|
* 只有至少一页真正识别到文本时才返回有效结果,全部失败返回 null。
|
||||||
*/
|
*/
|
||||||
private String tryOcrExtract(Path pdfPath, List<String> attempts) {
|
private OcrResult tryOcrExtract(Path pdfPath, List<String> attempts) {
|
||||||
Path tempDir = null;
|
Path tempDir = null;
|
||||||
try {
|
try {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
@ -290,9 +332,11 @@ public class DocumentExtractTool {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: 对每页执行 OCR
|
// Step 4: 对每页执行 OCR,统计成功/失败页数
|
||||||
long t2 = System.currentTimeMillis();
|
long t2 = System.currentTimeMillis();
|
||||||
StringBuilder ocrText = new StringBuilder();
|
StringBuilder ocrText = new StringBuilder();
|
||||||
|
int successPages = 0;
|
||||||
|
int failedPages = 0;
|
||||||
for (int i = 0; i < pageFiles.length; i++) {
|
for (int i = 0; i < pageFiles.length; i++) {
|
||||||
ocrText.append("--- Page ").append(i + 1).append(" ---\n");
|
ocrText.append("--- Page ").append(i + 1).append(" ---\n");
|
||||||
try {
|
try {
|
||||||
@ -305,17 +349,30 @@ public class DocumentExtractTool {
|
|||||||
cmd.add(langParam);
|
cmd.add(langParam);
|
||||||
}
|
}
|
||||||
String pageText = executeCommand(cmd);
|
String pageText = executeCommand(cmd);
|
||||||
ocrText.append(pageText != null ? pageText.trim() : "").append("\n\n");
|
if (pageText != null && !pageText.isBlank()) {
|
||||||
|
ocrText.append(pageText.trim()).append("\n\n");
|
||||||
|
successPages++;
|
||||||
|
} else {
|
||||||
|
ocrText.append("[空白页]\n\n");
|
||||||
|
failedPages++;
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[DocumentExtract] OCR: 页面 {} tesseract 失败: {}", i + 1, e.getMessage());
|
log.warn("[DocumentExtract] OCR: 页面 {} tesseract 失败: {}", i + 1, e.getMessage());
|
||||||
ocrText.append("[OCR 失败]\n\n");
|
failedPages++;
|
||||||
|
// 不写 [OCR 失败] 占位,避免被上层误判为有效文本
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.info("[DocumentExtract] OCR: tesseract 处理 {} 页耗时 {}ms,总耗时 {}ms",
|
log.info("[DocumentExtract] OCR: tesseract 处理 {} 页 (成功 {}, 失败 {}), 耗时 {}ms, 总耗时 {}ms",
|
||||||
pageFiles.length, System.currentTimeMillis() - t2, System.currentTimeMillis() - startTime);
|
pageFiles.length, successPages, failedPages,
|
||||||
|
System.currentTimeMillis() - t2, System.currentTimeMillis() - startTime);
|
||||||
|
|
||||||
String result = ocrText.toString().trim();
|
// 只有至少一页真正识别到文本时才算成功
|
||||||
return result.isEmpty() ? null : result;
|
if (successPages == 0) {
|
||||||
|
attempts.add("ocr: tesseract 运行但所有 " + pageFiles.length + " 页均未识别到文本");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OcrResult(ocrText.toString().trim(), pageFiles.length, successPages);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[DocumentExtract] OCR 失败: {}", e.getMessage());
|
log.warn("[DocumentExtract] OCR 失败: {}", e.getMessage());
|
||||||
|
|||||||
@ -630,11 +630,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
assistantMessage.conversationId = conversationId
|
assistantMessage.conversationId = conversationId
|
||||||
currentAssistantId.value = assistantMessage.id as string
|
currentAssistantId.value = assistantMessage.id as string
|
||||||
|
|
||||||
|
// contentParts 已由 buildOutgoingParts 包含 file entries,不要重复合并 attachments
|
||||||
await stream.connect({
|
await stream.connect({
|
||||||
agentId,
|
agentId,
|
||||||
message: content,
|
message: content,
|
||||||
conversationId,
|
conversationId,
|
||||||
contentParts: [...contentParts, ...attachments],
|
contentParts,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e : new Error(String(e))
|
error.value = e instanceof Error ? e : new Error(String(e))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user