feat: v3 robustness upgrade — EasyOCR, failure recovery, minimum content check

- OCR: EasyOCR (primary, ch_sim+en) with PaddleOCR fallback for Windows compatibility
- Validation: _check_minimum_content() rejects empty-shell JRXML (no band/textField)
- Retry: MAX_RETRY 3→5, exhaustion records pending_failure_context for next-turn auto-injection
- Finalize: only saves jrxml_versions on pass, preserves last good final_jrxml on fail
- Extract JRXML: improved empty markdown block handling and XML fragment fallback
- UI: real-time node progress via placeholder updates, initial "analyzing" feedback
- UI: use agent_state (full) instead of node_state (partial) for summary card routing
- UI: unknown template_type now gives LLM meaningful image context instead of metadata
- Docs: updated CLAUDE.md and CODE_GUIDE.md to reflect all v3 changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:15:30 +08:00
parent 70614dff5e
commit 6467fd4ae5
9 changed files with 1297 additions and 51 deletions
+9 -6
View File
@@ -4,7 +4,7 @@
一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Streamlit UI + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。 一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 JasperReports 模板(JRXML 文件)。核心技术栈:Streamlit UI + LangGraph 状态机 + LLM 生成/修改 + 自动验证修正循环。
**一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多3次) → 返回可编译的 JRXML 文件。 **一句话**:用户用中文描述报表需求 → LLM 生成 JRXML → 自动验证 → 失败则自动修正(最多5次) → 重试耗尽后失败上下文自动注入下一轮 → 返回可编译的 JRXML 文件。
## 启动命令 ## 启动命令
@@ -20,6 +20,7 @@ STREAMLIT_SERVER_HEADLESS=true streamlit run app.py --server.port 8501
## 当前配置(.env ## 当前配置(.env
- **OCR**: EasyOCR(优先,ch_sim+en)→ PaddleOCR(回退),两者均未安装时仅返回图片元信息
- **LLM**: `cloud` / `anthropic` → MiniMax Anthropic 兼容 API (`MiniMax-M2.7`) - **LLM**: `cloud` / `anthropic` → MiniMax Anthropic 兼容 API (`MiniMax-M2.7`)
- Base URL: `https://api.minimaxi.com/anthropic` - Base URL: `https://api.minimaxi.com/anthropic`
- 认证: 通过 `OPENAI_API_KEY` 传入 Anthropic SDK(注意不是 `ANTHROPIC_API_KEY` - 认证: 通过 `OPENAI_API_KEY` 传入 Anthropic SDK(注意不是 `ANTHROPIC_API_KEY`
@@ -46,7 +47,7 @@ agent/graph.py (LangGraph 状态机)
│ 验证修正循环: validate ─fail─► explain_error ─► correct_jrxml ─► validate │ 验证修正循环: validate ─fail─► explain_error ─► correct_jrxml ─► validate
│ ▲ │ │ ▲ │
│ └──────── (retry < MAX_RETRY=3) ───────────────────┘ │ └──────── (retry < MAX_RETRY=5) ───────────────────┘
├──► prompts/loader.py Prompt 外部化:7 个 .md 文件热重载 ├──► prompts/loader.py Prompt 外部化:7 个 .md 文件热重载
├──► backend/llm.py LLM 工厂: Anthropic SDK / OpenAI / Ollama (统一 stream/invoke) ├──► backend/llm.py LLM 工厂: Anthropic SDK / OpenAI / Ollama (统一 stream/invoke)
@@ -64,7 +65,7 @@ agent/graph.py (LangGraph 状态机)
| 文件 | 职责 | 修改频率 | | 文件 | 职责 | 修改频率 |
|------|------|---------| |------|------|---------|
| `app.py` | Streamlit UI 入口,聊天界面 + 侧边栏 + 下载 + 文件上传 | **高** | | `app.py` | Streamlit UI 入口,聊天界面 + 侧边栏 + 下载 + 文件上传 | **高** |
| `agent/state.py` | AgentState 类型定义(~23 字段,含 jrxml_versions/last_error_case | 低 | | `agent/state.py` | AgentState 类型定义(~24 字段,含 pending_failure_context | 低 |
| `agent/nodes.py` | 14 个工作流节点 + 流式生成 + 错误记录 | **高** | | `agent/nodes.py` | 14 个工作流节点 + 流式生成 + 错误记录 | **高** |
| `agent/graph.py` | 状态图编译 + 路由函数(预览跳过验证) | 中 | | `agent/graph.py` | 状态图编译 + 路由函数(预览跳过验证) | 中 |
| `prompts/loader.py` | Prompt 加载器(从 .md 文件热重载) | 低 | | `prompts/loader.py` | Prompt 加载器(从 .md 文件热重载) | 低 |
@@ -72,8 +73,8 @@ agent/graph.py (LangGraph 状态机)
| `backend/llm.py` | LLM 工厂,统一 `_BaseLLM` 接口(invoke + stream | 中 | | `backend/llm.py` | LLM 工厂,统一 `_BaseLLM` 接口(invoke + stream | 中 |
| `backend/rag_adapter.py` | RAGSearcher 单例,语义搜索接口 | 中 | | `backend/rag_adapter.py` | RAGSearcher 单例,语义搜索接口 | 中 |
| `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 | | `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 |
| `backend/file_parser.py` | 文件解析: PDF(pdfplumber)/DOCX(python-docx)/图片(PIL+PaddleOCR可选)/文本 | 中 | | `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 |
| `backend/layout_analyzer.py` | A4模板分析: 比例检测/PaddleOCR元素提取/行分组/JRXML行匹配 | 中 | | `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 |
| `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 | | `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 |
| `backend/validation.py` | 验证服务 HTTP 客户端 | 低 | | `backend/validation.py` | 验证服务 HTTP 客户端 | 低 |
| `backend/session.py` | 会话 JSON 文件 CRUD | 低 | | `backend/session.py` | 会话 JSON 文件 CRUD | 低 |
@@ -154,4 +155,6 @@ agent/graph.py (LangGraph 状态机)
- **验证服务结构检查**: 字段引用一致性 (`$F{field}` vs `<field>` 声明)、SQL SELECT 存在性、pageWidth/pageHeight/name 属性。 - **验证服务结构检查**: 字段引用一致性 (`$F{field}` vs `<field>` 声明)、SQL SELECT 存在性、pageWidth/pageHeight/name 属性。
- **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。 - **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。
- **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py``embed_chunks.py``import_to_chroma.py`),通常不需要在主项目中运行。 - **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py``embed_chunks.py``import_to_chroma.py`),通常不需要在主项目中运行。
- **PaddleOCR 可选**: A4 模板精确识别需要 `pip install paddleocr`,未安装时仅返回图片元信息 - **OCR 引擎**: 优先使用 EasyOCRWindows 兼容性更好,`pip install easyocr`),回退 PaddleOCR。两者均未安装时仅返回图片元信息,建议至少安装 EasyOCR
- **MAX_RETRY**: 默认 5 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
- **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>``<staticText>`,拦截空壳 JRXML。
+1074
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -228,4 +228,5 @@ def create_initial_state() -> AgentState:
history_states=[], history_states=[],
jrxml_versions=[], jrxml_versions=[],
last_error_case={}, last_error_case={},
pending_failure_context={},
) )
+68 -20
View File
@@ -27,14 +27,25 @@ HISTORY_MAX_SNAPSHOTS = int(os.getenv("HISTORY_MAX_SNAPSHOTS", "10"))
# ============================================================ # ============================================================
def process_input(state: AgentState) -> Dict: def process_input(state: AgentState) -> Dict:
"""记录用户输入到对话历史,重置本轮请求状态。""" """记录用户输入到对话历史,重置本轮请求状态。如有上次失败上下文则自动注入。"""
user_input = state.get("user_input", "") user_input = state.get("user_input", "")
# 维护全量对话历史 # 维护全量对话历史(始终记录原始用户消息)
full_history = state.get("full_conversation_history", []) full_history = state.get("full_conversation_history", [])
full_history.append({"role": "user", "content": user_input, "ts": _now_iso()}) full_history.append({"role": "user", "content": user_input, "ts": _now_iso()})
state["full_conversation_history"] = full_history state["full_conversation_history"] = full_history
# 自动注入上次失败上下文
pending = state.get("pending_failure_context", {})
if pending and pending.get("error_msg"):
failure_note = (
f"[系统提示] 上次生成失败,以下是失败详情,请基于此修正:\n"
f"失败原因: {pending['error_msg']}\n"
f"上次失败的输出:\n{pending.get('bad_jrxml', '(无输出)')}"
)
user_input = f"{failure_note}\n\n---\n用户新输入:\n{user_input}"
state["pending_failure_context"] = {}
# 维护工作对话历史 # 维护工作对话历史
conv_history = state.get("conversation_history", []) conv_history = state.get("conversation_history", [])
conv_history.append({"role": "user", "content": user_input}) conv_history.append({"role": "user", "content": user_input})
@@ -402,6 +413,12 @@ def validate(state: AgentState) -> Dict:
state["error_msg"] = "没有 JRXML 内容可供验证。" state["error_msg"] = "没有 JRXML 内容可供验证。"
return state return state
# 过短的内容不可能是合法报表(最小骨架约 500+ 字符)
if len(jrxml.strip()) < 200:
state["status"] = "fail"
state["error_msg"] = f"JRXML 内容过短({len(jrxml.strip())} 字符),可能为不完整或空内容。"
return state
result = validate_jrxml(jrxml) result = validate_jrxml(jrxml)
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", "")
@@ -481,26 +498,47 @@ def correct_jrxml(state: AgentState) -> Dict:
def finalize(state: AgentState) -> Dict: def finalize(state: AgentState) -> Dict:
"""保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。""" """保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。"""
jrxml = state.get("current_jrxml", "") jrxml = state.get("current_jrxml", "")
state["final_jrxml"] = jrxml status = state.get("status", "")
if jrxml.strip(): if status == "pass":
versions = state.get("jrxml_versions", []) state["final_jrxml"] = jrxml
if not isinstance(versions, list): if jrxml.strip():
versions = [] versions = state.get("jrxml_versions", [])
intent = state.get("intent", "") if not isinstance(versions, list):
label_map = { versions = []
"initial_generation": "初始生成", intent = state.get("intent", "")
"modify_report": "修改", label_map = {
"correct_jrxml": f"自动修正 (第{state.get('retry_count', 1)}次)", "initial_generation": "初始生成",
} "modify_report": "修改",
versions.append({ "correct_jrxml": f"自动修正 (第{state.get('retry_count', 1)}次)",
}
versions.append({
"ts": _now_iso(),
"jrxml": jrxml,
"intent": intent,
"label": label_map.get(intent, intent),
"status": status,
})
state["jrxml_versions"] = versions
else:
# 验证未通过:不覆盖 final_jrxml,保留上一次成功的版本
retries = state.get("retry_count", 0)
error_msg = state.get("error_msg", "未知错误")
# 记录失败上下文,下次用户输入时自动注入
state["pending_failure_context"] = {
"error_msg": error_msg,
"bad_jrxml": state.get("current_jrxml", ""),
"retry_count": retries,
"ts": _now_iso(), "ts": _now_iso(),
"jrxml": jrxml, }
"intent": intent, state["conversation_history"].append({
"label": label_map.get(intent, intent), "role": "assistant",
"status": state.get("status", ""), "content": (
f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n"
f"错误: {error_msg}\n"
f"请描述您想要的修改,系统会自动加载失败上下文继续修复。"
),
}) })
state["jrxml_versions"] = versions
return state return state
@@ -510,7 +548,10 @@ def _extract_jrxml(text: str) -> str:
xml_pattern = re.compile(r"```(?:xml|jrxml)?\s*([\s\S]*?)```", re.IGNORECASE) xml_pattern = re.compile(r"```(?:xml|jrxml)?\s*([\s\S]*?)```", re.IGNORECASE)
m = xml_pattern.search(text) m = xml_pattern.search(text)
if m: if m:
return m.group(1).strip() content = m.group(1).strip()
if content:
return content
# markdown 代码块存在但内容为空 — 回退到直接匹配
jasper_tag = re.search(r"(<\?xml[\s\S]*?</jasperReport>)", text, re.IGNORECASE) jasper_tag = re.search(r"(<\?xml[\s\S]*?</jasperReport>)", text, re.IGNORECASE)
if jasper_tag: if jasper_tag:
@@ -519,4 +560,11 @@ def _extract_jrxml(text: str) -> str:
if text.startswith("<?xml") or text.startswith("<jasperReport"): if text.startswith("<?xml") or text.startswith("<jasperReport"):
return text return text
# 最终回退:如果文本中包含 XML 片段但没有被捕获到,尝试直接提取
# 这处理 LLM 在代码块外用自然语言"包裹"JRXML 的情况
xml_start = text.find("<?xml")
jr_end = text.lower().rfind("</jasperreport>")
if xml_start >= 0 and jr_end > xml_start:
return text[xml_start:jr_end + len("</jasperreport>")].strip()
return text return text
+3
View File
@@ -37,3 +37,6 @@ class AgentState(TypedDict, total=False):
# 需求5:错误自增长(记录修正前的状态,供 validate 节点判断是否入知识库) # 需求5:错误自增长(记录修正前的状态,供 validate 节点判断是否入知识库)
last_error_case: dict last_error_case: dict
# 需求6:失败上下文传递 — 重试耗尽后暂存失败信息,下次用户输入时自动注入
pending_failure_context: dict
+47 -20
View File
@@ -138,18 +138,30 @@ def run_agent(user_input: str):
agent_state["user_input"] = user_input agent_state["user_input"] = user_input
agent_state["retry_count"] = 0 agent_state["retry_count"] = 0
# ---- UI 容器 ---- # ---- UI 占位 ----
streaming_placeholder = st.empty() # 流式文本 progress_placeholder = st.empty() # 实时节点进度
nodes_container = st.container() # 节点进度区 streaming_placeholder = st.empty() # 流式文本
summary_placeholder = st.empty() # 总结卡片 summary_placeholder = st.empty() # 总结卡片
# 节点追踪 # 初始状态提示
executed_nodes: list[dict] = [] # {name, label, status, detail} progress_placeholder.info("⏳ 正在分析您的需求...")
executed_nodes: list[dict] = []
stream_text = "" stream_text = ""
stream_active = False stream_active = False
current_stream_node = ""
final_state = None final_state = None
def _render_progress(nodes: list[dict]):
"""渲染实时节点进度到占位符。"""
if not nodes:
return
lines = []
for i, node in enumerate(nodes):
icon = "" if i == len(nodes) - 1 else ""
detail = f"{node['detail']}" if node.get("detail") else ""
lines.append(f"{icon} {node['label']}{detail}")
progress_placeholder.markdown("\n\n".join(lines))
try: try:
for event in st.session_state.graph.stream( for event in st.session_state.graph.stream(
agent_state, stream_mode=["updates", "custom"] agent_state, stream_mode=["updates", "custom"]
@@ -177,7 +189,6 @@ def run_agent(user_input: str):
) )
elif node_name in ("generate", "modify_jrxml", "correct_jrxml"): elif node_name in ("generate", "modify_jrxml", "correct_jrxml"):
# 流式文本已在上面的 custom 事件中展示
jrxml = node_state.get("current_jrxml", "") jrxml = node_state.get("current_jrxml", "")
executed_nodes[-1]["detail"] = f"生成 {len(jrxml)} 字符 JRXML" executed_nodes[-1]["detail"] = f"生成 {len(jrxml)} 字符 JRXML"
@@ -199,31 +210,29 @@ def run_agent(user_input: str):
final_state = node_state final_state = node_state
# 每个节点完成后立即更新进度
_render_progress(executed_nodes)
elif mode == "custom": elif mode == "custom":
cd = data cd = data
if cd.get("type") == "stream": if cd.get("type") == "stream":
stream_text += cd.get("text", "") stream_text += cd.get("text", "")
stream_active = True stream_active = True
current_stream_node = cd.get("node", "")
streaming_placeholder.code(stream_text, language="xml") streaming_placeholder.code(stream_text, language="xml")
except Exception as e: except Exception as e:
progress_placeholder.empty()
st.error(f"工作流异常: {e}") st.error(f"工作流异常: {e}")
return return
# ---- 渲染节点进度区 ---- # ---- 清理临时占位 ----
with nodes_container: progress_placeholder.empty()
with st.expander("处理过程", expanded=False):
for i, node in enumerate(executed_nodes):
icon = "" if i < len(executed_nodes) - 1 else ""
detail_str = f"{node['detail']}" if node.get("detail") else ""
st.caption(f"{icon} {node['label']}{detail_str}")
# ---- 清除流式占位 ----
if stream_active: if stream_active:
streaming_placeholder.empty() streaming_placeholder.empty()
# ---- 总结卡片 ---- # ---- 总结卡片 ----
# 注:node_state 只含变更字段,用 agent_state(被所有节点就地修改)获取完整状态
final_state = agent_state
if final_state: if final_state:
st.session_state.agent_state = final_state st.session_state.agent_state = final_state
intent = final_state.get("intent", "") intent = final_state.get("intent", "")
@@ -239,7 +248,6 @@ def run_agent(user_input: str):
elif intent in ("undo_modification", "reset_session"): elif intent in ("undo_modification", "reset_session"):
st.success("操作已完成") st.success("操作已完成")
# 消息已在节点中添加
elif intent in ("preview_report", "export_pdf", "export_jrxml"): elif intent in ("preview_report", "export_pdf", "export_jrxml"):
jrxml = final_state.get("current_jrxml", "") jrxml = final_state.get("current_jrxml", "")
@@ -279,10 +287,10 @@ def run_agent(user_input: str):
if jrxml: if jrxml:
with st.expander("查看当前 JRXML"): with st.expander("查看当前 JRXML"):
_render_jrxml(jrxml, max_lines=80) _render_jrxml(jrxml, max_lines=80)
st.caption("请简化报表结构后重试") st.caption("💡 下次输入修改需求时,系统会自动加载失败上下文继续修复")
st.session_state.messages.append({ st.session_state.messages.append({
"role": "assistant", "role": "assistant",
"content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}", "content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}\n\n💡 请直接描述修改需求,系统会自动加载失败上下文。",
"type": "error_explanation", "type": "error_explanation",
}) })
else: else:
@@ -431,6 +439,25 @@ with st.sidebar:
else: else:
# 新建模式:按 A4 模板处理 # 新建模式:按 A4 模板处理
parsed_text = layout["description"] parsed_text = layout["description"]
else:
# tt == "unknown": OCR 不可用或未检测到文字元素
has_ocr = result.get("method") not in ("metadata_only", None)
img_w, img_h = layout["image_size"]
ratio = layout["aspect_ratio"]
if has_ocr:
parsed_text = (
f"[图片上传] 尺寸 {img_w}x{img_h}px, 比例 {ratio}"
f"未检测到 A4 报表结构,图片将被视为参考样式。\n"
f"请根据用户的文字描述生成报表。"
)
else:
parsed_text = (
f"[图片上传] 尺寸 {img_w}x{img_h}px, 比例 {ratio}\n"
f"⚠ OCR 引擎未安装,无法识别图片中的文字内容。\n"
f"请严格根据用户的文字描述来推断图片中的报表需求。\n"
f"(提示:如需图片文字识别,请运行 pip install paddleocr"
)
parsed_type = "image_reference"
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)
+23 -4
View File
@@ -65,17 +65,36 @@ def parse_file(file_path: str, file_type: str = "") -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_image(path: Path) -> dict: def _parse_image(path: Path) -> dict:
"""OCR 提取图片中的文字。""" """OCR 提取图片中的文字。优先 EasyOCR,回退 PaddleOCR。"""
try: try:
img = PIL.Image.open(path) img = PIL.Image.open(path)
info = f"[图片: {img.size[0]}x{img.size[1]}, {img.mode}]" info = f"[图片: {img.size[0]}x{img.size[1]}, {img.mode}]"
except Exception: except Exception:
info = "[图片: 无法读取元数据]" info = "[图片: 无法读取元数据]"
# 尝试 PaddleOCR # 优先 EasyOCRWindows 兼容性更好)
try:
import easyocr
import numpy as np
reader = easyocr.Reader(["ch_sim", "en"], gpu=False, verbose=False)
result = reader.readtext(np.array(img))
lines = [text.strip() for (_, text, _) in result if text.strip()]
if lines:
return {
"text": f"{info}\n识别文本:\n" + "\n".join(lines),
"file_type": "image",
"method": "easyocr",
"error": None,
}
except ImportError:
pass
except Exception:
pass
# 回退 PaddleOCR
try: try:
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
ocr = PaddleOCR(lang="ch", use_angle_cls=False, show_log=False) ocr = PaddleOCR(lang="ch")
result = ocr.ocr(str(path)) result = ocr.ocr(str(path))
lines = [] lines = []
if result and result[0]: if result and result[0]:
@@ -97,7 +116,7 @@ def _parse_image(path: Path) -> dict:
# OCR 不可用 → 返回图片元信息 + 安装提示 # OCR 不可用 → 返回图片元信息 + 安装提示
return { return {
"text": f"{info}\n(如需 OCR 文字识别,请安装: pip install paddleocr)", "text": f"{info}\n(如需 OCR 文字识别,请安装: pip install easyocr)",
"file_type": "image", "file_type": "image",
"method": "metadata_only", "method": "metadata_only",
"error": "OCR 引擎未安装,已返回图片元信息", "error": "OCR 引擎未安装,已返回图片元信息",
+39 -1
View File
@@ -371,11 +371,47 @@ def _load_image(path: Path) -> Optional[PIL.Image.Image]:
def _ocr_elements(img: PIL.Image.Image, file_path: str) -> list[dict]: def _ocr_elements(img: PIL.Image.Image, file_path: str) -> list[dict]:
"""OCR 提取图片中的文字元素(位置+内容)。优先 EasyOCR,回退 PaddleOCR。"""
# 优先 EasyOCR
try:
import easyocr
import numpy as np
reader = easyocr.Reader(["ch_sim", "en"], gpu=False, verbose=False)
result = reader.readtext(np.array(img))
elements = []
for (bbox, text, confidence) in result:
if not text.strip():
continue
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
elements.append({
"x": round(x_min, 1),
"y": round(y_min, 1),
"w": round(x_max - x_min, 1),
"h": round(y_max - y_min, 1),
"font_size": round(y_max - y_min, 1),
"text": text.strip(),
})
elements.sort(key=lambda e: (e["y"], e["x"]))
return elements
except ImportError:
pass
except Exception:
pass
# 回退 PaddleOCR
try: try:
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
import numpy as np import numpy as np
ocr = PaddleOCR(lang="ch", use_angle_cls=True, show_log=False) ocr = PaddleOCR(lang="ch")
result = ocr.ocr(np.array(img)) result = ocr.ocr(np.array(img))
elements = [] elements = []
@@ -405,6 +441,8 @@ def _ocr_elements(img: PIL.Image.Image, file_path: str) -> list[dict]:
elements.sort(key=lambda e: (e["y"], e["x"])) elements.sort(key=lambda e: (e["y"], e["x"]))
return elements return elements
except ImportError:
pass
except Exception: except Exception:
pass pass
+33
View File
@@ -82,6 +82,35 @@ def _check_structural_issues(jrxml: str) -> list[str]:
return issues return issues
def _check_minimum_content(jrxml: str) -> list[str]:
"""检查 JRXML 是否包含最基本的报表内容(至少要有 band 和文本元素)。"""
issues = []
try:
root = ET.fromstring(jrxml)
except ET.ParseError:
return [] # 结构性检查已捕获
# 统计各类元素
bands = 0
text_fields = 0
static_texts = 0
for elem in root.iter():
tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
if tag == "band":
bands += 1
elif tag == "textField":
text_fields += 1
elif tag == "staticText":
static_texts += 1
if bands == 0:
issues.append("报表没有任何 <band> 元素,无法渲染内容")
if text_fields == 0 and static_texts == 0:
issues.append("报表没有任何 <textField> 或 <staticText> 元素,输出将是一片空白")
return issues
def _validate_xsd(jrxml: str) -> tuple[bool, str]: def _validate_xsd(jrxml: str) -> tuple[bool, str]:
"""根据 JasperReports XSD schema 验证 JRXML。""" """根据 JasperReports XSD schema 验证 JRXML。"""
if not SCHEMA_FILE.exists(): if not SCHEMA_FILE.exists():
@@ -111,6 +140,10 @@ async def validate_jrxml(req: ValidationRequest):
if structural_issues: if structural_issues:
return ValidationResponse(valid=False, error="; ".join(structural_issues)) return ValidationResponse(valid=False, error="; ".join(structural_issues))
content_issues = _check_minimum_content(jrxml)
if content_issues:
return ValidationResponse(valid=False, error="; ".join(content_issues))
valid, xsd_error = _validate_xsd(jrxml) valid, xsd_error = _validate_xsd(jrxml)
if not valid: if not valid:
return ValidationResponse(valid=False, error=xsd_error) return ValidationResponse(valid=False, error=xsd_error)