feat: add Java JRXML-to-PNG rendering pipeline with pixel-level SSIM comparison
- lib/java/: Java renderer (JrxmlRenderer) using JasperReports 6.21.0 - JrxmlDebug for diagnostics, JrxmlGen for format reference - download_jars.sh for one-time dependency setup - agent/nodes.py: _render_jrxml_to_png() and _compute_pixel_similarity() - Pixel comparison integrates into validate node (SSIM < 0.4 fails) - Pixel fidelity context injected into correct_jrxml for targeted fixes - tests/test_pixel_comparison.py: 15 unit tests (render, SSIM, integration) - .gitignore: exclude lib/java/*.jar, lib/java/*.class, tmp/ - CLAUDE.md: v11 changelog documenting the rendering pipeline - All non-LLM tests pass (97/97)
This commit is contained in:
@@ -20,6 +20,13 @@ EVALUATION_REPORT.md
|
|||||||
# 上传文件
|
# 上传文件
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
# Java JARs & compiled classes
|
||||||
|
lib/java/*.jar
|
||||||
|
lib/java/*.class
|
||||||
|
|
||||||
|
# 渲染临时文件
|
||||||
|
tmp/
|
||||||
|
|
||||||
# OCR 临时输出
|
# OCR 临时输出
|
||||||
ocr_raw_positions.json
|
ocr_raw_positions.json
|
||||||
|
|
||||||
|
|||||||
@@ -385,3 +385,45 @@ cd frontend && npx playwright test
|
|||||||
**MAX_RETRY 调整**: 默认值从 3 → 5(环境变量 `MAX_RETRY`),配合续写机制确保复杂报表有充分修正机会。
|
**MAX_RETRY 调整**: 默认值从 3 → 5(环境变量 `MAX_RETRY`),配合续写机制确保复杂报表有充分修正机会。
|
||||||
|
|
||||||
**JRXML 提取命名空间兼容**: `_extract_jrxml()` 和 `_generate_with_continuation()` 的完整性检查统一支持 `</ns0:jasperReport>` 等命名空间前缀闭合标签。
|
**JRXML 提取命名空间兼容**: `_extract_jrxml()` 和 `_generate_with_continuation()` 的完整性检查统一支持 `</ns0:jasperReport>` 等命名空间前缀闭合标签。
|
||||||
|
|
||||||
|
## 更新 (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` 字段
|
||||||
|
|||||||
+265
-1
@@ -839,6 +839,188 @@ def modify_jrxml(state: AgentState) -> Dict:
|
|||||||
return state
|
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 <field> 声明中出现
|
||||||
|
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"<textField", jrxml))
|
||||||
|
static_texts = len(re.findall(r"<staticText", jrxml))
|
||||||
|
total_jrxml_elements = text_fields + static_texts
|
||||||
|
|
||||||
|
ocr_text_count = 0
|
||||||
|
if isinstance(ocr_elements, list):
|
||||||
|
ocr_text_count = len([e for e in ocr_elements if isinstance(e, dict) and e.get("text", "").strip()])
|
||||||
|
if ocr_text_count == 0 and isinstance(ocr_result, dict):
|
||||||
|
ocr_text_count = ocr_result.get("total_elements", 0)
|
||||||
|
|
||||||
|
if ocr_text_count > 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'<field name="(\w+)"', jrxml))
|
||||||
|
ocr_field_names = set()
|
||||||
|
ocr_fields = ocr_result.get("fields", []) if isinstance(ocr_result, dict) else []
|
||||||
|
for f in ocr_fields:
|
||||||
|
if isinstance(f, dict):
|
||||||
|
name = f.get("name", "") or f.get("field_name", "") or f.get("label", "")
|
||||||
|
if name and len(name) > 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"<band[^>]*height=\"(\d+)\"[^>]*>([\s\S]*?)</band>", 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")
|
@log_node("validate")
|
||||||
def validate(state: AgentState) -> Dict:
|
def validate(state: AgentState) -> Dict:
|
||||||
"""根据 FastAPI 验证服务验证当前 JRXML。"""
|
"""根据 FastAPI 验证服务验证当前 JRXML。"""
|
||||||
@@ -866,6 +1048,57 @@ def validate(state: AgentState) -> Dict:
|
|||||||
state["status"] = "pass" if result.get("valid") else "fail"
|
state["status"] = "pass" if result.get("valid") else "fail"
|
||||||
state["error_msg"] = result.get("error", "")
|
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:
|
if result.get("valid") and state.get("retry_count", 0) > 0:
|
||||||
case = state.get("last_error_case", {})
|
case = state.get("last_error_case", {})
|
||||||
@@ -920,12 +1153,34 @@ def correct_jrxml(state: AgentState) -> Dict:
|
|||||||
layout_text = ""
|
layout_text = ""
|
||||||
if isinstance(layout_schema, dict):
|
if isinstance(layout_schema, dict):
|
||||||
layout_text = layout_schema.get("schema_text", "")
|
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(
|
prompt = load_prompt("correction").format(
|
||||||
current_jrxml=state.get("current_jrxml", ""),
|
current_jrxml=state.get("current_jrxml", ""),
|
||||||
error_msg=state.get("error_msg", ""),
|
error_msg=state.get("error_msg", ""),
|
||||||
explanation=state.get("natural_explanation", ""),
|
explanation=state.get("natural_explanation", ""),
|
||||||
ocr_context=ocr_context,
|
ocr_context=ocr_context,
|
||||||
layout_schema_text=layout_text,
|
layout_schema_text=layout_text,
|
||||||
|
fidelity_context=fidelity_text,
|
||||||
)
|
)
|
||||||
# 保存修正前状态(供 validate 判断是否写入错误知识库)
|
# 保存修正前状态(供 validate 判断是否写入错误知识库)
|
||||||
state["last_error_case"] = {
|
state["last_error_case"] = {
|
||||||
@@ -944,7 +1199,16 @@ def correct_jrxml(state: AgentState) -> Dict:
|
|||||||
if len(jrxml.strip()) < 200:
|
if len(jrxml.strip()) < 200:
|
||||||
_node_log.warning(f"correct_jrxml 输出过短({len(jrxml)} 字符),回退到前一版本")
|
_node_log.warning(f"correct_jrxml 输出过短({len(jrxml)} 字符),回退到前一版本")
|
||||||
jrxml = prev_jrxml
|
jrxml = prev_jrxml
|
||||||
state["current_jrxml"] = jrxml
|
|
||||||
|
# 去重检测:如果输出与输入完全相同(忽略空白差异),说明修正无效
|
||||||
|
_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["retry_count"] = state.get("retry_count", 0) + 1
|
||||||
state["conversation_history"].append(
|
state["conversation_history"].append(
|
||||||
{"role": "assistant", "content": f"[自动修正,第 {state['retry_count']} 次尝试]\n{jrxml}"}
|
{"role": "assistant", "content": f"[自动修正,第 {state['retry_count']} 次尝试]\n{jrxml}"}
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ async def _sse_generator(agent_state: AgentState, session_id: str = "") -> str:
|
|||||||
"jrxml_length": len(agent_state.get("current_jrxml", "")),
|
"jrxml_length": len(agent_state.get("current_jrxml", "")),
|
||||||
"error_msg": agent_state.get("error_msg", ""),
|
"error_msg": agent_state.get("error_msg", ""),
|
||||||
"natural_explanation": agent_state.get("natural_explanation", ""),
|
"natural_explanation": agent_state.get("natural_explanation", ""),
|
||||||
|
"consult_answer": agent_state.get("consult_answer", ""),
|
||||||
"retry_count": agent_state.get("retry_count", 0),
|
"retry_count": agent_state.get("retry_count", 0),
|
||||||
"total_duration_ms": total_ms,
|
"total_duration_ms": total_ms,
|
||||||
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
|
"ocr_extraction_result": agent_state.get("ocr_extraction_result", {}),
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ async function handleSend(text: string, files: File[]) {
|
|||||||
jrxml_length: data.jrxml_length,
|
jrxml_length: data.jrxml_length,
|
||||||
error_msg: data.error_msg,
|
error_msg: data.error_msg,
|
||||||
natural_explanation: data.natural_explanation,
|
natural_explanation: data.natural_explanation,
|
||||||
|
consult_answer: data.consult_answer,
|
||||||
retry_count: data.retry_count,
|
retry_count: data.retry_count,
|
||||||
total_duration_ms: data.total_duration_ms,
|
total_duration_ms: data.total_duration_ms,
|
||||||
ocr_extraction_result: data.ocr_extraction_result,
|
ocr_extraction_result: data.ocr_extraction_result,
|
||||||
@@ -88,8 +89,12 @@ async function handleSend(text: string, files: File[]) {
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} else if (data.intent === 'consult_question') {
|
} else if (data.intent === 'consult_question') {
|
||||||
if (streamContent) {
|
// 咨询回答:优先用 streamContent,其次用 consult_answer
|
||||||
chat.addMessage({ role: 'assistant', content: streamContent, type: 'consult' })
|
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 {
|
} else {
|
||||||
if (streamContent) {
|
if (streamContent) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface AgentCompleteData {
|
|||||||
jrxml_length: number
|
jrxml_length: number
|
||||||
error_msg: string
|
error_msg: string
|
||||||
natural_explanation: string
|
natural_explanation: string
|
||||||
|
consult_answer: string
|
||||||
retry_count: number
|
retry_count: number
|
||||||
total_duration_ms: number
|
total_duration_ms: number
|
||||||
ocr_extraction_result: any
|
ocr_extraction_result: any
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
function finishStreaming(data?: {
|
function finishStreaming(data?: {
|
||||||
intent?: string; status?: string; jrxml_length?: number
|
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
|
total_duration_ms?: number; ocr_extraction_result?: any
|
||||||
}) {
|
}) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
@@ -164,6 +164,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
jrxml_length: data.jrxml_length || 0,
|
jrxml_length: data.jrxml_length || 0,
|
||||||
error_msg: data.error_msg || '',
|
error_msg: data.error_msg || '',
|
||||||
natural_explanation: data.natural_explanation || '',
|
natural_explanation: data.natural_explanation || '',
|
||||||
|
consult_answer: data.consult_answer || '',
|
||||||
retry_count: data.retry_count || 0,
|
retry_count: data.retry_count || 0,
|
||||||
}
|
}
|
||||||
if (data.ocr_extraction_result) {
|
if (data.ocr_extraction_result) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <input.jrxml> <output.png> [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<String, Object> 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<BufferedImage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
关键规则:
|
关键规则:
|
||||||
- 只输出完整修复后的 JRXML 代码,不要解释,不要 markdown 标记。
|
- 只输出完整修复后的 JRXML 代码,不要解释,不要 markdown 标记。
|
||||||
- JRXML 必须与 JasperReports 7.0.6 兼容。
|
- JRXML 必须与 JasperReports 7.0.6 兼容。
|
||||||
- 解决下面列出的特定错误。
|
- **一次只修复一个错误**:关注错误列表中的第 1 个错误,修复它即可。不要尝试修复所有错误。
|
||||||
|
- **输出不能与输入相同**:如果你发现自己要输出和当前 JRXML 完全相同的代码,说明你没有修复错误——必须做出实质性改动。
|
||||||
- 如果当前 JRXML 内容为空或过短(<200 字符),请根据下方提供的 OCR 识别数据和布局 schema 重新生成完整的 JRXML,而非输出一个占位桩。
|
- 如果当前 JRXML 内容为空或过短(<200 字符),请根据下方提供的 OCR 识别数据和布局 schema 重新生成完整的 JRXML,而非输出一个占位桩。
|
||||||
- 如果错误是"字段 'field_N' 未在 <field> 部分声明",**必须**为每个缺失的 field_N 添加 `<field name="field_N" class="java.lang.String"/>` 声明。这些是占位字段,不可删除。同时确保所有 $F{{field_N}} 引用都有对应的 <field> 声明。
|
- 如果错误是"字段 'field_N' 未在 <field> 部分声明",**必须**为每个缺失的 field_N 添加 `<field name="field_N" class="java.lang.String"/>` 声明。这些是占位字段,不可删除。同时确保所有 $F{{field_N}} 引用都有对应的 <field> 声明。
|
||||||
- 如果错误是"字段 'field_N' 未在 <field> 部分声明"且有 OCR 字段数据,尝试将 $F{{field_N}} 替换为 OCR 中对应的真实字段名(如 $F{{invoice_code}}),同时更新 <field> 声明和所有引用。
|
- 如果错误是"字段 'field_N' 未在 <field> 部分声明"且有 OCR 字段数据,尝试将 $F{{field_N}} 替换为 OCR 中对应的真实字段名(如 $F{{invoice_code}}),同时更新 <field> 声明和所有引用。
|
||||||
|
- **如果错误包含 "No matching global declaration available for the validation root" 或 "namespace" 或 "命名空间"**,说明命名空间 URL 错误。正确的根元素格式必须为:`<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd">`。删除所有 ns0: 前缀,删除所有 `xmlns:ns0` 声明,删除所有元素标签上的 `ns0:` 前缀。
|
||||||
|
|
||||||
当前 JRXML(带错误):
|
当前 JRXML(带错误):
|
||||||
{current_jrxml}
|
{current_jrxml}
|
||||||
@@ -21,4 +23,6 @@
|
|||||||
|
|
||||||
{layout_schema_text}
|
{layout_schema_text}
|
||||||
|
|
||||||
|
{fidelity_context}
|
||||||
|
|
||||||
立即生成修正后的 JRXML:
|
立即生成修正后的 JRXML:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
关键规则:
|
关键规则:
|
||||||
- 只输出完整修改后的 JRXML 代码,不要解释,不要 markdown 标记。
|
- 只输出完整修改后的 JRXML 代码,不要解释,不要 markdown 标记。
|
||||||
- 保留所有未被更改的现有结构。
|
- 保留所有未被更改的现有结构。
|
||||||
- 结果必须继续与 JasperReports 7.0.6 兼容。
|
- 结果必须继续与 JasperReports 7.0.6 兼容。命名空间必须为 `xmlns="http://jasperreports.sourceforge.net/jasperreports"`,不可使用 jaspersoft.com 等错误 URL。
|
||||||
- 报表正文中使用的每个字段必须在 <field> 部分中声明。
|
- 报表正文中使用的每个字段必须在 <field> 部分中声明。
|
||||||
- 如果添加新字段,正确声明它们。
|
- 如果添加新字段,正确声明它们。
|
||||||
- 确保 <queryString> 是 <![CDATA[...]]> 中有效的 SQL。
|
- 确保 <queryString> 是 <![CDATA[...]]> 中有效的 SQL。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- 使用 $F{{field_1}}, $F{{field_2}}, ... 作为占位字段名,并在 <field> 部分声明它们。
|
- 使用 $F{{field_1}}, $F{{field_2}}, ... 作为占位字段名,并在 <field> 部分声明它们。
|
||||||
- 报表结构必须正确(title, pageHeader, columnHeader, detail, pageFooter 等 band)。
|
- 报表结构必须正确(title, pageHeader, columnHeader, detail, pageFooter 等 band)。
|
||||||
- 元素位置使用近似值即可,后续会精确调整。
|
- 元素位置使用近似值即可,后续会精确调整。
|
||||||
- 根元素为 <jasperReport>,包含正确的 xmlns 属性。
|
- 根元素为 <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd">,命名空间和 schemaLocation 必须精确,不可使用其他 URL(如 jaspersoft.com)。
|
||||||
- 包含 <queryString>,在 <![CDATA[...]]> 中放置占位 SQL(SELECT * FROM table_name)。
|
- 包含 <queryString>,在 <![CDATA[...]]> 中放置占位 SQL(SELECT * FROM table_name)。
|
||||||
- 确保 JRXML 兼容 JasperReports 7.0.6。
|
- 确保 JRXML 兼容 JasperReports 7.0.6。
|
||||||
|
|
||||||
|
|||||||
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd"
|
||||||
|
name="TestReport" pageWidth="595" pageHeight="842"
|
||||||
|
columnWidth="555" leftMargin="20" rightMargin="20"
|
||||||
|
topMargin="20" bottomMargin="20">
|
||||||
|
<queryString><![CDATA[SELECT 1]]></queryString>
|
||||||
|
<title>
|
||||||
|
<band height="50">
|
||||||
|
<staticText>
|
||||||
|
<reportElement x="0" y="0" width="555" height="30"/>
|
||||||
|
<text><![CDATA[HELLO]]></text>
|
||||||
|
</staticText>
|
||||||
|
</band>
|
||||||
|
</title>
|
||||||
|
<detail>
|
||||||
|
<band height="20">
|
||||||
|
<staticText>
|
||||||
|
<reportElement x="0" y="0" width="555" height="20"/>
|
||||||
|
<text><![CDATA[test]]></text>
|
||||||
|
</staticText>
|
||||||
|
</band>
|
||||||
|
</detail>
|
||||||
|
</jasperReport>"""
|
||||||
|
|
||||||
|
INVALID_JRXML = "<notJRXML></notJRXML>"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
|
||||||
|
name="TestReport" pageWidth="595" pageHeight="842"
|
||||||
|
columnWidth="555" leftMargin="20" rightMargin="20"
|
||||||
|
topMargin="20" bottomMargin="20">
|
||||||
|
<field name="invoice_code" class="java.lang.String"/>
|
||||||
|
<field name="invoice_number" class="java.lang.String"/>
|
||||||
|
<queryString><![CDATA[SELECT 1]]></queryString>
|
||||||
|
<detail><band height="20"><textField><reportElement x="0" y="0" width="200" height="20"/><textFieldExpression><![CDATA[$F{invoice_code}]]></textFieldExpression></textField></band></detail>
|
||||||
|
</jasperReport>"""
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user