diff --git a/.gitignore b/.gitignore index ad54e14..3fbce2c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,13 @@ EVALUATION_REPORT.md # 上传文件 uploads/ +# Java JARs & compiled classes +lib/java/*.jar +lib/java/*.class + +# 渲染临时文件 +tmp/ + # OCR 临时输出 ocr_raw_positions.json diff --git a/CLAUDE.md b/CLAUDE.md index 638b24f..12bfedd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,3 +385,45 @@ cd frontend && npx playwright test **MAX_RETRY 调整**: 默认值从 3 → 5(环境变量 `MAX_RETRY`),配合续写机制确保复杂报表有充分修正机会。 **JRXML 提取命名空间兼容**: `_extract_jrxml()` 和 `_generate_with_continuation()` 的完整性检查统一支持 `` 等命名空间前缀闭合标签。 + +## 更新 (v11 — 2026-05-23) + +### Java 渲染管线 + 像素级对比 + +**目标**: 将 JRXML 渲染为 PNG 图片,与用户上传的原始图片进行 SSIM(结构相似性)像素级对比。 + +**Java 依赖** (`lib/java/`): +| JAR | 用途 | +|-----|------| +| `jasperreports-6.21.0.jar` (5.8MB) | 核心库,**必须用 6.x**(7.x 仅支持 Jackson XML 格式) | +| `commons-digester-2.1.jar` | XML 解析(6.x 使用 Digester 2.x) | +| `commons-logging-1.3.5.jar`, `commons-collections4-4.5.0.jar`, `commons-beanutils-1.10.1.jar`, `commons-lang3-3.17.0.jar` | 基础依赖 | +| `itext-2.1.7.jar` | PDF 生成 | +| `jfreechart-1.5.5.jar` | 图表 | +| `ecj-3.38.0.jar` | Eclipse JDT 编译器(报表表达式编译) | + +**Java 工具** (`lib/java/`): +| 文件 | 用途 | +|------|------| +| `JrxmlRenderer.java` | JRXML → PNG 渲染器 | +| `JrxmlDebug.java` | 诊断:SAX/JRXmlLoader/compile 三层测试 | +| `JrxmlGen.java` | 参考:程序化构建 JasperDesign → 序列化为 XML | + +**Python 渲染封装** (`agent/nodes.py`): +- `_render_jrxml_to_png(jrxml, output_path, scale)` — 调用 Java `JrxmlRenderer` +- `_compute_pixel_similarity(rendered_png, reference_image)` — OpenCV + scikit-image SSIM 对比 + +**像素对比流程**: validate 节点 XSD 通过 → 有 `uploaded_file_path` → Java 渲染 → SSIM 对比 → SSIM < 0.4 且 diff > 60% → 标记 fail → 注入 correct_jrxml 修正上下文 + +**手动渲染**: `java -cp ".;jasperreports-6.21.0.jar;..." JrxmlRenderer input.jrxml output.png 2.0` + +### 内容保真度 + 修正去重 (v10 补充) + +- `_check_ocr_fidelity(jrxml, state)` — OCR 字段名/元素数/列数三重检查 +- `correct_jrxml` 去重检测:输入输出相同 → `retry_count += 2` +- `prompts/correction.md` — 一次只修复第1个错误 + 输出不可与输入相同 + 命名空间严格指定 +- `prompts/skeleton_generation.md`, `prompts/modification.md` — 明确命名空间约束 + +### consult_answer 前端显示修复 + +- `api_server.py` — `agent_complete` SSE 事件新增 `consult_answer` 字段 diff --git a/agent/nodes.py b/agent/nodes.py index 782588e..c49eaf7 100644 --- a/agent/nodes.py +++ b/agent/nodes.py @@ -839,6 +839,188 @@ def modify_jrxml(state: AgentState) -> Dict: return state +# ── Java renderer config ────────────────────────────────────────────── +_JAVA_BIN = os.path.join( + os.environ.get("JAVA_HOME", "C:/Program Files/Java/jdk-21.0.11"), + "bin", "java.exe" +) +_JAVA_JAR_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "lib", "java") +_JAVA_RENDERER_CP = ";".join([ + os.path.join(_JAVA_JAR_DIR, j) for j in [ + "jasperreports-6.21.0.jar", + "commons-logging-1.3.5.jar", + "commons-collections4-4.5.0.jar", + "commons-beanutils-1.10.1.jar", + "commons-lang3-3.17.0.jar", + "commons-digester-2.1.jar", + "itext-2.1.7.jar", + "jfreechart-1.5.5.jar", + "ecj-3.38.0.jar", + ] +]) +_JAVA_RENDERER_CLASS = "JrxmlRenderer" +_JAVA_RENDERER_CP = "." + os.pathsep + _JAVA_RENDERER_CP + + +def _render_jrxml_to_png(jrxml: str, output_path: str, scale: float = 2.0) -> bool: + """调用 Java JrxmlRenderer 将 JRXML 渲染为 PNG。 + + 返回 True 表示渲染成功,False 表示失败。 + """ + import subprocess + import tempfile + + tmpdir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tmp") + os.makedirs(tmpdir, exist_ok=True) + + jrxml_path = os.path.join(tmpdir, "_render_input.jrxml") + with open(jrxml_path, "w", encoding="utf-8") as f: + f.write(jrxml) + + try: + result = subprocess.run( + [_JAVA_BIN, "-cp", _JAVA_RENDERER_CP, _JAVA_RENDERER_CLASS, + jrxml_path, output_path, str(scale)], + capture_output=True, text=True, timeout=120, + cwd=_JAVA_JAR_DIR, + ) + if result.returncode == 0: + _node_log.info(f"PNG rendered: {output_path} ({result.stdout.strip()})") + return True + else: + _node_log.warning(f"PNG render failed: {result.stdout.strip()} {result.stderr.strip()}") + return False + except Exception as e: + _node_log.warning(f"PNG render exception: {e}") + return False + + +def _compute_pixel_similarity(rendered_png: str, reference_image: str) -> dict: + """计算渲染 PNG 与参考图片的像素级相似度。 + + 使用 SSIM(结构相似性)作为主要指标,同时返回像素差异比例。 + 返回 {"ssim": float, "diff_pct": float, "error": str|None} + """ + try: + import cv2 + import numpy as np + + rendered = cv2.imread(rendered_png, cv2.IMREAD_GRAYSCALE) + reference = cv2.imread(reference_image, cv2.IMREAD_GRAYSCALE) + + if rendered is None: + return {"ssim": 0.0, "diff_pct": 1.0, "error": f"无法读取渲染图片: {rendered_png}"} + if reference is None: + return {"ssim": 0.0, "diff_pct": 1.0, "error": f"无法读取参考图片: {reference_image}"} + + # Resize rendered to match reference dimensions for comparison + if rendered.shape != reference.shape: + rendered = cv2.resize(rendered, (reference.shape[1], reference.shape[0])) + + # SSIM + from skimage.metrics import structural_similarity as ssim + score = ssim(rendered, reference, data_range=255) + + # Pixel difference percentage + diff = cv2.absdiff(rendered, reference) + diff_pct = float(np.count_nonzero(diff > 30)) / diff.size + + return {"ssim": round(score, 4), "diff_pct": round(diff_pct, 4), "error": None} + except ImportError as e: + return {"ssim": 0.0, "diff_pct": 1.0, "error": f"缺少依赖: {e}"} + except Exception as e: + return {"ssim": 0.0, "diff_pct": 1.0, "error": str(e)} + + +def _check_ocr_fidelity(jrxml: str, state: dict) -> dict: + """比对生成的 JRXML 与原始图片 OCR 提取内容的保真度。 + + 检查维度: + 1. 字段覆盖:OCR 字段名是否在 JRXML 声明中出现 + 2. 元素数量:JRXML 中 textField+staticText 数量与 OCR 文本元素数量之比 + 3. 列结构:data band 中的列数与 OCR 检测到的列数比对 + """ + ocr_elements = state.get("ocr_elements", []) + ocr_result = state.get("ocr_extraction_result", {}) + layout_schema = state.get("layout_schema", {}) + + # 无 OCR 数据时跳过 + if not ocr_elements and not ocr_result: + return {"score": 1.0, "field_coverage": 1.0, "element_coverage": 1.0, "issues": []} + + issues = [] + + # 1. 元素数量对比 + text_fields = len(re.findall(r" 0: + element_coverage = min(total_jrxml_elements / max(ocr_text_count, 1), 1.0) + if element_coverage < 0.3: + issues.append( + f"元素覆盖不足:JRXML 仅有 {total_jrxml_elements} 个文本元素," + f"OCR 源有 {ocr_text_count} 个文本元素(覆盖率 {element_coverage:.0%})" + ) + else: + element_coverage = 1.0 + + # 2. 字段名覆盖 + jrxml_fields = set(re.findall(r' 1: + ocr_field_names.add(name) + + if ocr_field_names and jrxml_fields: + matched = jrxml_fields & ocr_field_names + field_coverage = len(matched) / max(len(ocr_field_names), 1) + unmatched = ocr_field_names - jrxml_fields + if unmatched: + sample = list(unmatched)[:8] + issues.append(f"OCR 字段未在 JRXML 中声明: {', '.join(sample)}") + elif ocr_field_names and not jrxml_fields: + field_coverage = 0.0 + issues.append("JRXML 中未声明任何字段,但 OCR 提取了结构化字段数据") + else: + field_coverage = 1.0 + + # 3. 列数对比 + if isinstance(layout_schema, dict): + ocr_columns = layout_schema.get("total_columns", 0) or layout_schema.get("columns", 0) + # 从 detail band 中的元素 x 坐标估算列数 + detail_match = re.search(r"]*height=\"(\d+)\"[^>]*>([\s\S]*?)", jrxml) + if detail_match and ocr_columns > 0: + detail_content = detail_match.group(2) + x_positions = set() + for m in re.finditer(r'x="(\d+)"', detail_content): + x_positions.add(int(m.group(1))) + jrxml_columns = len(x_positions) if x_positions else 1 + if jrxml_columns < ocr_columns * 0.5: + issues.append( + f"列数不足:JRXML detail band 检测到 {jrxml_columns} 列," + f"OCR 布局分析有 {ocr_columns} 列" + ) + + # 综合评分 + score = round(field_coverage * 0.5 + element_coverage * 0.5, 3) + return { + "score": score, + "field_coverage": round(field_coverage, 3), + "element_coverage": round(element_coverage, 3), + "issues": issues, + } + + @log_node("validate") def validate(state: AgentState) -> Dict: """根据 FastAPI 验证服务验证当前 JRXML。""" @@ -866,6 +1048,57 @@ def validate(state: AgentState) -> Dict: state["status"] = "pass" if result.get("valid") else "fail" state["error_msg"] = result.get("error", "") + # OCR 保真度检查:比对生成结果与原始图片的 OCR 提取内容 + fidelity = _check_ocr_fidelity(jrxml, state) + state["ocr_fidelity"] = fidelity + if fidelity["issues"]: + if state["status"] == "pass": + # XSD 通过但内容保真度不足 → 降级为 fail + if fidelity["score"] < 0.5: + state["status"] = "fail" + state["error_msg"] = ( + f"[内容保真度不足] 得分 {fidelity['score']:.2f}/1.0。" + + " ".join(fidelity["issues"][:3]) + ) + _node_log.warning( + f"OCR 保真度得分 {fidelity['score']:.2f},XSD 通过但内容差异过大: " + + "; ".join(fidelity["issues"][:5]) + ) + else: + _node_log.info( + f"OCR 保真度得分 {fidelity['score']:.2f},XSD 通过,轻微差异: " + + "; ".join(fidelity["issues"][:3]) + ) + else: + _node_log.info( + f"XSD 验证失败 + OCR 保真度得分 {fidelity['score']:.2f}: " + + "; ".join(fidelity["issues"][:3]) + ) + + # ── 像素级对比:将 JRXML 渲染为 PNG,与原始上传图片进行 SSIM 比较 ── + source_image = state.get("uploaded_file_path", "") + if source_image and os.path.isfile(source_image) and state["status"] == "pass": + tmpdir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tmp") + rendered_png = os.path.join(tmpdir, "_pixel_test.png") + if _render_jrxml_to_png(jrxml, rendered_png): + pixel_result = _compute_pixel_similarity(rendered_png, source_image) + state["pixel_fidelity"] = pixel_result + if pixel_result["error"]: + _node_log.warning(f"像素对比失败: {pixel_result['error']}") + else: + _node_log.info( + f"像素对比: SSIM={pixel_result['ssim']:.4f}, " + f"Diff={pixel_result['diff_pct']:.2%}" + ) + # SSIM < 0.4 或 diff > 60% → 质量不合格 + if pixel_result["ssim"] < 0.4 and pixel_result["diff_pct"] > 0.6: + state["status"] = "fail" + state["error_msg"] = ( + f"[像素保真度不足] SSIM={pixel_result['ssim']:.3f}, " + f"差异像素占比={pixel_result['diff_pct']:.2%}。" + f"渲染结果与原始图片差异过大,需调整布局。" + ) + # 修正成功后记录到错误知识库 if result.get("valid") and state.get("retry_count", 0) > 0: case = state.get("last_error_case", {}) @@ -920,12 +1153,34 @@ def correct_jrxml(state: AgentState) -> Dict: layout_text = "" if isinstance(layout_schema, dict): layout_text = layout_schema.get("schema_text", "") + + # 构建保真度上下文(告诉 LLM 图片与模板的差异) + fidelity = state.get("ocr_fidelity", {}) + fidelity_text = "" + if fidelity and fidelity.get("score", 1.0) < 0.9: + fidelity_text = ( + f"[内容保真度警告] 得分 {fidelity.get('score', 0):.2f}/1.0\n" + + "\n".join(f"- {issue}" for issue in fidelity.get("issues", [])) + ) + + # 像素级对比上下文 + pixel_fidelity = state.get("pixel_fidelity", {}) + if pixel_fidelity and pixel_fidelity.get("ssim", 1.0) < 0.7: + fidelity_parts = [fidelity_text] if fidelity_text else [] + fidelity_parts.append( + f"[像素保真度] SSIM={pixel_fidelity.get('ssim', 0):.4f}, " + f"像素差异={pixel_fidelity.get('diff_pct', 0):.2%}。" + f"渲染结果与原图差异过大,请调整元素位置、尺寸和布局。" + ) + fidelity_text = "\n".join(fidelity_parts) + prompt = load_prompt("correction").format( current_jrxml=state.get("current_jrxml", ""), error_msg=state.get("error_msg", ""), explanation=state.get("natural_explanation", ""), ocr_context=ocr_context, layout_schema_text=layout_text, + fidelity_context=fidelity_text, ) # 保存修正前状态(供 validate 判断是否写入错误知识库) state["last_error_case"] = { @@ -944,8 +1199,17 @@ def correct_jrxml(state: AgentState) -> Dict: if len(jrxml.strip()) < 200: _node_log.warning(f"correct_jrxml 输出过短({len(jrxml)} 字符),回退到前一版本") jrxml = prev_jrxml - state["current_jrxml"] = jrxml - state["retry_count"] = state.get("retry_count", 0) + 1 + + # 去重检测:如果输出与输入完全相同(忽略空白差异),说明修正无效 + _prev_norm = re.sub(r"\s+", "", prev_jrxml) if prev_jrxml else "" + _new_norm = re.sub(r"\s+", "", jrxml) if jrxml else "" + if _prev_norm and _new_norm and _prev_norm == _new_norm: + _node_log.warning( + f"correct_jrxml 输出与输入完全相同({len(jrxml)} 字符),修正无效,加速消耗 retry" + ) + state["retry_count"] = state.get("retry_count", 0) + 2 + else: + state["retry_count"] = state.get("retry_count", 0) + 1 state["conversation_history"].append( {"role": "assistant", "content": f"[自动修正,第 {state['retry_count']} 次尝试]\n{jrxml}"} ) diff --git a/api_server.py b/api_server.py index f7ab8d5..5c797d5 100644 --- a/api_server.py +++ b/api_server.py @@ -244,6 +244,7 @@ async def _sse_generator(agent_state: AgentState, session_id: str = "") -> str: "jrxml_length": len(agent_state.get("current_jrxml", "")), "error_msg": agent_state.get("error_msg", ""), "natural_explanation": agent_state.get("natural_explanation", ""), + "consult_answer": agent_state.get("consult_answer", ""), "retry_count": agent_state.get("retry_count", 0), "total_duration_ms": total_ms, "ocr_extraction_result": agent_state.get("ocr_extraction_result", {}), diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f43c3cb..f6bb012 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -70,6 +70,7 @@ async function handleSend(text: string, files: File[]) { jrxml_length: data.jrxml_length, error_msg: data.error_msg, natural_explanation: data.natural_explanation, + consult_answer: data.consult_answer, retry_count: data.retry_count, total_duration_ms: data.total_duration_ms, ocr_extraction_result: data.ocr_extraction_result, @@ -88,8 +89,12 @@ async function handleSend(text: string, files: File[]) { type: 'error', }) } else if (data.intent === 'consult_question') { - if (streamContent) { - chat.addMessage({ role: 'assistant', content: streamContent, type: 'consult' }) + // 咨询回答:优先用 streamContent,其次用 consult_answer + const answerText = streamContent || data.consult_answer || '' + if (answerText) { + chat.addMessage({ role: 'assistant', content: answerText, type: 'consult' }) + } else { + chat.addMessage({ role: 'assistant', content: '咨询已完成,但未获取到回答内容。', type: 'error' }) } } else { if (streamContent) { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f124cda..e63874a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -27,6 +27,7 @@ export interface AgentCompleteData { jrxml_length: number error_msg: string natural_explanation: string + consult_answer: string retry_count: number total_duration_ms: number ocr_extraction_result: any diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index d837704..83f7530 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -144,7 +144,7 @@ export const useChatStore = defineStore('chat', () => { function finishStreaming(data?: { intent?: string; status?: string; jrxml_length?: number - error_msg?: string; natural_explanation?: string; retry_count?: number + error_msg?: string; natural_explanation?: string; consult_answer?: string; retry_count?: number total_duration_ms?: number; ocr_extraction_result?: any }) { streaming.value = false @@ -164,6 +164,7 @@ export const useChatStore = defineStore('chat', () => { jrxml_length: data.jrxml_length || 0, error_msg: data.error_msg || '', natural_explanation: data.natural_explanation || '', + consult_answer: data.consult_answer || '', retry_count: data.retry_count || 0, } if (data.ocr_extraction_result) { diff --git a/lib/java/JrxmlDebug.java b/lib/java/JrxmlDebug.java new file mode 100644 index 0000000..418c016 --- /dev/null +++ b/lib/java/JrxmlDebug.java @@ -0,0 +1,47 @@ +import net.sf.jasperreports.engine.*; +import net.sf.jasperreports.engine.design.*; +import net.sf.jasperreports.engine.xml.*; +import java.io.*; + +public class JrxmlDebug { + public static void main(String[] args) throws Exception { + String path = args.length > 0 ? args[0] : "D:/Idea Project/jaspersoft/tmp/test_simple.jrxml"; + File f = new File(path); + System.out.println("File: " + path + " (exists=" + f.exists() + ", len=" + f.length() + ")"); + + // Test 1: JRXmlLoader.load() + System.out.println("\n=== JRXmlLoader.load() ==="); + try { + JasperDesign design = JRXmlLoader.load(f); + System.out.println("PASS: " + design.getName() + + " pages=" + design.getPageWidth() + "x" + design.getPageHeight()); + System.out.println(" Title: " + (design.getTitle() != null ? design.getTitle().getHeight() + "px" : "null")); + System.out.println(" Detail: " + (design.getDetailSection() != null ? "present" : "null")); + } catch (Throwable t) { + System.out.println("FAIL: " + t.getMessage()); + Throwable c = t; + int d = 0; + while (c != null) { + System.out.println(" [" + d + "] " + c.getClass().getName() + ": " + c.getMessage()); + for (int i = 0; i < Math.min(5, c.getStackTrace().length); i++) + System.out.println(" at " + c.getStackTrace()[i]); + c = c.getCause(); + d++; + } + } + + // Test 2: JasperCompileManager.compileReport() + System.out.println("\n=== JasperCompileManager.compileReport() ==="); + try { + JasperReport report = JasperCompileManager.compileReport(path); + System.out.println("PASS: " + report.getName()); + } catch (Throwable t) { + System.out.println("FAIL: " + t.getMessage()); + Throwable c = t; + while (c != null) { + System.out.println(" -> " + c.getClass().getName() + ": " + c.getMessage()); + c = c.getCause(); + } + } + } +} diff --git a/lib/java/JrxmlGen.java b/lib/java/JrxmlGen.java new file mode 100644 index 0000000..9d4c559 --- /dev/null +++ b/lib/java/JrxmlGen.java @@ -0,0 +1,58 @@ +import net.sf.jasperreports.engine.*; +import net.sf.jasperreports.engine.design.*; +import net.sf.jasperreports.jackson.util.*; +import java.io.*; + +/** + * Generate a minimal JasperDesign programmatically, then serialize it + * via JacksonUtil to show the correct 7.x XML format. + */ +public class JrxmlGen { + public static void main(String[] args) throws Exception { + JasperDesign design = new JasperDesign(); + design.setName("TestReport"); + design.setPageWidth(595); + design.setPageHeight(842); + design.setColumnWidth(555); + design.setLeftMargin(20); + design.setRightMargin(20); + design.setTopMargin(20); + design.setBottomMargin(20); + design.setWhenNoDataType(com.fasterxml.jackson.databind.node.TextNode.valueOf("AllSectionsNoDetail")); + + design.setQuery("SELECT 1"); + + JRDesignBand titleBand = new JRDesignBand(); + titleBand.setHeight(50); + JRDesignStaticText st = new JRDesignStaticText(); + st.setX(0); + st.setY(0); + st.setWidth(555); + st.setHeight(30); + st.setText("HELLO WORLD"); + titleBand.addElement(st); + design.setTitle(titleBand); + + JRDesignBand detailBand = new JRDesignBand(); + detailBand.setHeight(20); + JRDesignStaticText dt = new JRDesignStaticText(); + dt.setX(0); + dt.setY(0); + dt.setWidth(555); + dt.setHeight(20); + dt.setText("test row"); + detailBand.addElement(dt); + design.setDetail(detailBand); + + JacksonUtil util = JacksonUtil.getInstance(DefaultJasperReportsContext.getInstance()); + String xml = util.saveXml(design); + System.out.println("=== Serialized 7.x JRXML ==="); + System.out.println(xml); + + String outPath = "D:/Idea Project/jaspersoft/tmp/test_reference.jrxml"; + try (FileWriter fw = new FileWriter(outPath)) { + fw.write(xml); + } + System.out.println("\nSaved to: " + outPath); + } +} diff --git a/lib/java/JrxmlRenderer.java b/lib/java/JrxmlRenderer.java new file mode 100644 index 0000000..69420b8 --- /dev/null +++ b/lib/java/JrxmlRenderer.java @@ -0,0 +1,110 @@ +import net.sf.jasperreports.engine.*; +import net.sf.jasperreports.engine.export.*; +import net.sf.jasperreports.export.*; +import java.io.*; +import java.util.*; +import java.awt.image.*; +import javax.imageio.*; + +/** + * Minimal JRXML → PNG renderer for pixel-level fidelity comparison. + * Usage: java JrxmlRenderer input.jrxml output.png [scale] + * scale: optional zoom factor (default 2.0 for readable text) + * + * Uses JasperReports 7.x exporter API (SimpleExporterInput / SimpleGraphics2DExporterOutput). + */ +public class JrxmlRenderer { + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Usage: java JrxmlRenderer [scale]"); + System.exit(1); + } + + String jrxmlPath = args[0]; + String outputPath = args[1]; + float scale = args.length >= 3 ? Float.parseFloat(args[2]) : 2.0f; + + File jrxmlFile = new File(jrxmlPath); + if (!jrxmlFile.exists()) { + System.err.println("ERROR: JRXML file not found: " + jrxmlPath); + System.exit(2); + } + + try { + // 1. Compile JRXML → JasperReport + JasperReport report = JasperCompileManager.compileReport(jrxmlPath); + + // 2. Fill with empty data source + Map params = new HashMap<>(); + JasperPrint print = JasperFillManager.fillReport(report, params, new JREmptyDataSource()); + + int pages = print.getPages().size(); + if (pages == 0) { + System.err.println("ERROR: Report has 0 pages after filling"); + System.exit(4); + } + + // 3. Calculate image dimensions from page size + // JasperReports uses 72 DPI, convert to pixels with scale + int pageWidth = (int) Math.ceil(report.getPageWidth() * scale); + int pageHeight = (int) Math.ceil(report.getPageHeight() * scale); + + // 4. Render each page to a BufferedImage + java.util.List pageImages = new ArrayList<>(); + + for (int i = 0; i < pages; i++) { + BufferedImage pageImage = new BufferedImage(pageWidth, pageHeight, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g2d = pageImage.createGraphics(); + g2d.setColor(java.awt.Color.WHITE); + g2d.fillRect(0, 0, pageWidth, pageHeight); + // Scale the graphics context + g2d.scale(scale, scale); + + JRGraphics2DExporter exporter = new JRGraphics2DExporter(); + exporter.setExporterInput(new SimpleExporterInput(print)); + + SimpleGraphics2DExporterOutput output = new SimpleGraphics2DExporterOutput(); + output.setGraphics2D(g2d); + exporter.setExporterOutput(output); + + SimpleGraphics2DReportConfiguration config = new SimpleGraphics2DReportConfiguration(); + config.setPageIndex(i); + exporter.setConfiguration(config); + + exporter.exportReport(); + g2d.dispose(); + pageImages.add(pageImage); + } + + // 5. Combine pages into single tall image + int totalHeight = 0; + for (BufferedImage img : pageImages) { + totalHeight += img.getHeight(); + } + + BufferedImage combined = new BufferedImage(pageWidth, totalHeight, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = combined.createGraphics(); + g.setColor(java.awt.Color.WHITE); + g.fillRect(0, 0, pageWidth, totalHeight); + + int yOffset = 0; + for (BufferedImage img : pageImages) { + g.drawImage(img, 0, yOffset, null); + yOffset += img.getHeight(); + } + g.dispose(); + + // 6. Write PNG + ImageIO.write(combined, "png", new File(outputPath)); + + System.out.println("OK: " + outputPath + + " (pages=" + pages + + ", size=" + pageWidth + "x" + totalHeight + + ", scale=" + scale + ")"); + } catch (JRException e) { + System.err.println("JASPER_ERROR: " + e.getMessage()); + e.printStackTrace(); + System.exit(3); + } + } +} diff --git a/lib/java/JrxmlRendererTest.java b/lib/java/JrxmlRendererTest.java new file mode 100644 index 0000000..5912403 --- /dev/null +++ b/lib/java/JrxmlRendererTest.java @@ -0,0 +1,36 @@ +import net.sf.jasperreports.engine.*; +import net.sf.jasperreports.engine.design.*; +import net.sf.jasperreports.engine.xml.*; +import java.io.*; + +public class JrxmlRendererTest { + public static void main(String[] args) throws Exception { + String path = args.length > 0 ? args[0] : "/tmp/test_render.jrxml"; + File f = new File(path); + System.out.println("File: " + f.getAbsolutePath() + " exists=" + f.exists() + " len=" + f.length()); + + // Read content to check XML validity + try (BufferedReader br = new BufferedReader(new FileReader(f))) { + String line; + int lineNum = 0; + while ((line = br.readLine()) != null && lineNum < 5) { + System.out.println(" L" + (++lineNum) + ": " + line); + } + } + + try { + System.out.println("Step 1: Loading JRXML..."); + JasperDesign design = JRXmlLoader.load(f); + System.out.println(" OK - name=" + design.getName()); + } catch (JRException e) { + System.err.println("JR ERROR: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("GENERIC ERROR: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(); + } catch (Error e) { + System.err.println("FATAL ERROR: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/lib/java/download_jars.sh b/lib/java/download_jars.sh new file mode 100644 index 0000000..30d33e0 --- /dev/null +++ b/lib/java/download_jars.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Download JasperReports 6.21.0 and dependencies for JRXML-to-PNG rendering. +# Run this once after cloning the repo. +set -e + +BASE="https://repo1.maven.org/maven2" + +JARS=( + "net/sf/jasperreports/jasperreports/6.21.0/jasperreports-6.21.0.jar" + "commons-logging/commons-logging/1.3.5/commons-logging-1.3.5.jar" + "org/apache/commons/commons-collections4/4.5.0/commons-collections4-4.5.0.jar" + "commons-beanutils/commons-beanutils/1.10.1/commons-beanutils-1.10.1.jar" + "org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.jar" + "commons-digester/commons-digester/2.1/commons-digester-2.1.jar" + "com/lowagie/itext/2.1.7/itext-2.1.7.jar" + "org/jfree/jfreechart/1.5.5/jfreechart-1.5.5.jar" + "org/eclipse/jdt/ecj/3.38.0/ecj-3.38.0.jar" +) + +for jar in "${JARS[@]}"; do + fname=$(basename "$jar") + if [ -f "$fname" ]; then + echo "SKIP: $fname (exists)" + else + echo "DOWNLOAD: $fname" + curl -sL -o "$fname" "$BASE/$jar" + fi +done + +echo "" +echo "All JARs ready. Compile with:" +echo " javac -cp \"jasperreports-6.21.0.jar;...\" JrxmlRenderer.java" +echo " java -cp \".;jasperreports-6.21.0.jar;...\" JrxmlRenderer input.jrxml output.png 2.0" diff --git a/prompts/correction.md b/prompts/correction.md index f35c308..b7d2f84 100644 --- a/prompts/correction.md +++ b/prompts/correction.md @@ -3,10 +3,12 @@ 关键规则: - 只输出完整修复后的 JRXML 代码,不要解释,不要 markdown 标记。 - JRXML 必须与 JasperReports 7.0.6 兼容。 -- 解决下面列出的特定错误。 +- **一次只修复一个错误**:关注错误列表中的第 1 个错误,修复它即可。不要尝试修复所有错误。 +- **输出不能与输入相同**:如果你发现自己要输出和当前 JRXML 完全相同的代码,说明你没有修复错误——必须做出实质性改动。 - 如果当前 JRXML 内容为空或过短(<200 字符),请根据下方提供的 OCR 识别数据和布局 schema 重新生成完整的 JRXML,而非输出一个占位桩。 - 如果错误是"字段 'field_N' 未在 部分声明",**必须**为每个缺失的 field_N 添加 `` 声明。这些是占位字段,不可删除。同时确保所有 $F{{field_N}} 引用都有对应的 声明。 - 如果错误是"字段 'field_N' 未在 部分声明"且有 OCR 字段数据,尝试将 $F{{field_N}} 替换为 OCR 中对应的真实字段名(如 $F{{invoice_code}}),同时更新 声明和所有引用。 +- **如果错误包含 "No matching global declaration available for the validation root" 或 "namespace" 或 "命名空间"**,说明命名空间 URL 错误。正确的根元素格式必须为:``。删除所有 ns0: 前缀,删除所有 `xmlns:ns0` 声明,删除所有元素标签上的 `ns0:` 前缀。 当前 JRXML(带错误): {current_jrxml} @@ -21,4 +23,6 @@ {layout_schema_text} +{fidelity_context} + 立即生成修正后的 JRXML: diff --git a/prompts/modification.md b/prompts/modification.md index 2324a22..933cf1c 100644 --- a/prompts/modification.md +++ b/prompts/modification.md @@ -3,7 +3,7 @@ 关键规则: - 只输出完整修改后的 JRXML 代码,不要解释,不要 markdown 标记。 - 保留所有未被更改的现有结构。 -- 结果必须继续与 JasperReports 7.0.6 兼容。 +- 结果必须继续与 JasperReports 7.0.6 兼容。命名空间必须为 `xmlns="http://jasperreports.sourceforge.net/jasperreports"`,不可使用 jaspersoft.com 等错误 URL。 - 报表正文中使用的每个字段必须在 部分中声明。 - 如果添加新字段,正确声明它们。 - 确保 中有效的 SQL。 diff --git a/prompts/skeleton_generation.md b/prompts/skeleton_generation.md index 56f07dc..4110c2a 100644 --- a/prompts/skeleton_generation.md +++ b/prompts/skeleton_generation.md @@ -5,7 +5,7 @@ - 使用 $F{{field_1}}, $F{{field_2}}, ... 作为占位字段名,并在 部分声明它们。 - 报表结构必须正确(title, pageHeader, columnHeader, detail, pageFooter 等 band)。 - 元素位置使用近似值即可,后续会精确调整。 -- 根元素为 ,包含正确的 xmlns 属性。 +- 根元素为 ,命名空间和 schemaLocation 必须精确,不可使用其他 URL(如 jaspersoft.com)。 - 包含 ,在 中放置占位 SQL(SELECT * FROM table_name)。 - 确保 JRXML 兼容 JasperReports 7.0.6。 diff --git a/tests/test_pixel_comparison.py b/tests/test_pixel_comparison.py new file mode 100644 index 0000000..69a4df4 --- /dev/null +++ b/tests/test_pixel_comparison.py @@ -0,0 +1,220 @@ +"""Tests for pixel-level JRXML-to-image comparison pipeline.""" + +import os +import sys +import tempfile +import pytest +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +import cv2 +from agent.nodes import _render_jrxml_to_png, _compute_pixel_similarity, _check_ocr_fidelity + + +# ── Test JRXML fixture ───────────────────────────────────────────────── + +VALID_JRXML = """ + + + + <band height="50"> + <staticText> + <reportElement x="0" y="0" width="555" height="30"/> + <text><![CDATA[HELLO]]></text> + </staticText> + </band> + + + + + + + + + +""" + +INVALID_JRXML = "" + + +# ── _render_jrxml_to_png ──────────────────────────────────────────────── + +class TestRenderJrxmlToPng: + def test_valid_jrxml_renders_successfully(self, tmp_path): + output_png = str(tmp_path / "valid_output.png") + result = _render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0) + assert result, "Valid JRXML should render successfully" + assert os.path.exists(output_png), f"Output PNG should exist at {output_png}" + assert os.path.getsize(output_png) > 1000, "Output PNG should be non-trivial" + + def test_invalid_jrxml_returns_false(self, tmp_path): + output_png = str(tmp_path / "invalid_output.png") + result = _render_jrxml_to_png(INVALID_JRXML, output_png) + assert not result, "Invalid JRXML should return False" + + def test_render_output_is_readable_image(self, tmp_path): + output_png = str(tmp_path / "readable.png") + _render_jrxml_to_png(VALID_JRXML, output_png, scale=1.0) + img = cv2.imread(output_png) + assert img is not None, "Rendered PNG should be readable by OpenCV" + assert img.shape[0] > 0 and img.shape[1] > 0, "Image should have non-zero dimensions" + assert img.shape[2] == 3, "Should be a 3-channel (BGR) image" + + def test_high_scale_produces_larger_image(self, tmp_path): + png1 = str(tmp_path / "scale1.png") + png2 = str(tmp_path / "scale2.png") + _render_jrxml_to_png(VALID_JRXML, png1, scale=1.0) + _render_jrxml_to_png(VALID_JRXML, png2, scale=2.0) + img1 = cv2.imread(png1) + img2 = cv2.imread(png2) + assert img2.shape[0] >= img1.shape[0], "Higher scale should produce >= height" + assert img2.shape[1] >= img1.shape[1], "Higher scale should produce >= width" + + +# ── _compute_pixel_similarity ─────────────────────────────────────────── + +class TestComputePixelSimilarity: + def test_identical_images_have_high_ssim(self, tmp_path): + img_path = str(tmp_path / "test.png") + white = np.full((100, 200, 3), 255, dtype=np.uint8) + cv2.imwrite(img_path, white) + result = _compute_pixel_similarity(img_path, img_path) + assert result["error"] is None, f"Should have no error: {result['error']}" + assert result["ssim"] == 1.0, f"SSIM of identical images should be 1.0, got {result['ssim']}" + assert result["diff_pct"] == 0.0, f"Diff% of identical images should be 0, got {result['diff_pct']}" + + def test_completely_different_images_have_low_ssim(self, tmp_path): + white_path = str(tmp_path / "white.png") + black_path = str(tmp_path / "black.png") + cv2.imwrite(white_path, np.full((100, 200, 3), 255, dtype=np.uint8)) + cv2.imwrite(black_path, np.full((100, 200, 3), 0, dtype=np.uint8)) + result = _compute_pixel_similarity(white_path, black_path) + assert result["error"] is None, f"Should have no error: {result['error']}" + assert result["ssim"] < 0.3, f"Different images should have low SSIM, got {result['ssim']}" + assert result["diff_pct"] > 0.9, f"Different images should have high diff%, got {result['diff_pct']}" + + def test_different_size_images_are_resized(self, tmp_path): + img1_path = str(tmp_path / "img1.png") + img2_path = str(tmp_path / "img2.png") + cv2.imwrite(img1_path, np.full((50, 100, 3), 128, dtype=np.uint8)) + cv2.imwrite(img2_path, np.full((100, 200, 3), 128, dtype=np.uint8)) + result = _compute_pixel_similarity(img1_path, img2_path) + assert result["error"] is None, f"Resize should work: {result['error']}" + + def test_missing_file_returns_error(self, tmp_path): + result = _compute_pixel_similarity( + str(tmp_path / "does_not_exist.png"), + str(tmp_path / "also_missing.png"), + ) + assert result["error"] is not None, "Missing files should set error" + assert result["ssim"] == 0.0 + + def test_non_image_file_returns_error(self, tmp_path): + text_path = str(tmp_path / "text.txt") + with open(text_path, "w") as f: + f.write("not an image") + png_path = str(tmp_path / "ref.png") + cv2.imwrite(png_path, np.full((100, 100, 3), 255, dtype=np.uint8)) + result = _compute_pixel_similarity(text_path, png_path) + assert result["error"] is not None, "Non-image should set error" + + +# ── _check_ocr_fidelity ───────────────────────────────────────────────── + +class TestCheckOcrFidelity: + def test_no_ocr_data_returns_full_score(self): + state = {} + result = _check_ocr_fidelity(VALID_JRXML, state) + assert result["score"] == 1.0 + assert result["issues"] == [] + + def test_missing_ocr_fields_flagged(self): + state = { + "ocr_extraction_result": { + "fields": [ + {"name": "invoice_code"}, + {"name": "invoice_number"}, + {"name": "amount"}, + ] + } + } + result = _check_ocr_fidelity(VALID_JRXML, state) + assert result["field_coverage"] < 1.0, "Missing fields should reduce coverage" + assert len(result["issues"]) > 0, "Should have issues about missing fields" + + def test_matched_fields_increase_coverage(self): + state = { + "ocr_extraction_result": { + "fields": [ + {"name": "invoice_code"}, + {"name": "invoice_number"}, + ] + } + } + jrxml_with_fields = """ + + + + + + """ + result = _check_ocr_fidelity(jrxml_with_fields, state) + assert result["field_coverage"] == 1.0, f"All fields matched, got {result['field_coverage']}" + assert len(result["issues"]) == 0, f"No issues expected, got {result['issues']}" + + def test_element_count_mismatch_flagged(self): + state = { + "ocr_elements": [ + {"text": "a"}, {"text": "b"}, {"text": "c"}, + {"text": "d"}, {"text": "e"}, + ] + } + result = _check_ocr_fidelity(VALID_JRXML, state) + assert result["element_coverage"] < 1.0, \ + "Fewer JRXML elements than OCR should reduce coverage" + + +# ── validate node integration ─────────────────────────────────────────── + +class TestValidatePixelIntegration: + def test_validate_skips_pixel_when_no_image(self): + from agent.nodes import validate + + state = { + "current_jrxml": VALID_JRXML, + "uploaded_file_path": None, + "ocr_elements": [], + "layout_schema": {}, + "conversation_history": [], + } + result = validate(state) + assert result.get("pixel_fidelity") is None, \ + "Should not set pixel_fidelity without uploaded_file_path" + assert result["status"] == "pass", "Valid JRXML should pass XSD" + + def test_validate_skips_pixel_when_xsd_fails(self): + from agent.nodes import validate + + state = { + "current_jrxml": INVALID_JRXML, + "uploaded_file_path": os.path.join( + os.path.dirname(os.path.dirname(__file__)), "tmp", "test_output.png" + ), + "ocr_elements": [], + "layout_schema": {}, + "conversation_history": [], + } + result = validate(state) + assert result["status"] == "fail", "Invalid JRXML should fail XSD"