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:
@@ -4,7 +4,7 @@
|
||||
|
||||
一个**本地桌面应用**,通过自然语言多轮对话帮助非技术用户创建 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)
|
||||
|
||||
- **OCR**: EasyOCR(优先,ch_sim+en)→ PaddleOCR(回退),两者均未安装时仅返回图片元信息
|
||||
- **LLM**: `cloud` / `anthropic` → MiniMax Anthropic 兼容 API (`MiniMax-M2.7`)
|
||||
- Base URL: `https://api.minimaxi.com/anthropic`
|
||||
- 认证: 通过 `OPENAI_API_KEY` 传入 Anthropic SDK(注意不是 `ANTHROPIC_API_KEY`)
|
||||
@@ -46,7 +47,7 @@ agent/graph.py (LangGraph 状态机)
|
||||
│
|
||||
│ 验证修正循环: validate ─fail─► explain_error ─► correct_jrxml ─► validate
|
||||
│ ▲ │
|
||||
│ └──────── (retry < MAX_RETRY=3) ───────────────────┘
|
||||
│ └──────── (retry < MAX_RETRY=5) ───────────────────┘
|
||||
│
|
||||
├──► prompts/loader.py Prompt 外部化:7 个 .md 文件热重载
|
||||
├──► backend/llm.py LLM 工厂: Anthropic SDK / OpenAI / Ollama (统一 stream/invoke)
|
||||
@@ -64,7 +65,7 @@ agent/graph.py (LangGraph 状态机)
|
||||
| 文件 | 职责 | 修改频率 |
|
||||
|------|------|---------|
|
||||
| `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/graph.py` | 状态图编译 + 路由函数(预览跳过验证) | 中 |
|
||||
| `prompts/loader.py` | Prompt 加载器(从 .md 文件热重载) | 低 |
|
||||
@@ -72,8 +73,8 @@ agent/graph.py (LangGraph 状态机)
|
||||
| `backend/llm.py` | LLM 工厂,统一 `_BaseLLM` 接口(invoke + stream) | 中 |
|
||||
| `backend/rag_adapter.py` | RAGSearcher 单例,语义搜索接口 | 中 |
|
||||
| `backend/error_kb.py` | ErrorKB — 错误指纹去重 + ChromaDB 持久化 + 语义检索 | 中 |
|
||||
| `backend/file_parser.py` | 文件解析: PDF(pdfplumber)/DOCX(python-docx)/图片(PIL+PaddleOCR可选)/文本 | 中 |
|
||||
| `backend/layout_analyzer.py` | A4模板分析: 比例检测/PaddleOCR元素提取/行分组/JRXML行匹配 | 中 |
|
||||
| `backend/file_parser.py` | 文件解析: PDF/DOCX/图片(EasyOCR→PaddleOCR回退)/文本 | 中 |
|
||||
| `backend/layout_analyzer.py` | A4模板分析: 比例检测/EasyOCR→PaddleOCR元素提取/行分组/JRXML行匹配 | 中 |
|
||||
| `backend/embeddings.py` | 嵌入模型工厂 (HuggingFace/OpenAI) | 低 |
|
||||
| `backend/validation.py` | 验证服务 HTTP 客户端 | 低 |
|
||||
| `backend/session.py` | 会话 JSON 文件 CRUD | 低 |
|
||||
@@ -154,4 +155,6 @@ agent/graph.py (LangGraph 状态机)
|
||||
- **验证服务结构检查**: 字段引用一致性 (`$F{field}` vs `<field>` 声明)、SQL SELECT 存在性、pageWidth/pageHeight/name 属性。
|
||||
- **XSD 校验可选**: 需要 `validation_service/schemas/jasperreport_7_0_6.xsd` 存在。
|
||||
- **rag 子模块**: 内部有独立的管线脚本(`batch_chunker.py` → `embed_chunks.py` → `import_to_chroma.py`),通常不需要在主项目中运行。
|
||||
- **PaddleOCR 可选**: A4 模板精确识别需要 `pip install paddleocr`,未安装时仅返回图片元信息。
|
||||
- **OCR 引擎**: 优先使用 EasyOCR(Windows 兼容性更好,`pip install easyocr`),回退 PaddleOCR。两者均未安装时仅返回图片元信息,建议至少安装 EasyOCR。
|
||||
- **MAX_RETRY**: 默认 5 次。重试耗尽后 `pending_failure_context` 记录失败信息,下次用户输入时自动注入。
|
||||
- **验证最小内容检查**: 验证服务额外检查至少 1 个 `<band>` + 1 个 `<textField>` 或 `<staticText>`,拦截空壳 JRXML。
|
||||
|
||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -228,4 +228,5 @@ def create_initial_state() -> AgentState:
|
||||
history_states=[],
|
||||
jrxml_versions=[],
|
||||
last_error_case={},
|
||||
pending_failure_context={},
|
||||
)
|
||||
|
||||
+68
-20
@@ -27,14 +27,25 @@ HISTORY_MAX_SNAPSHOTS = int(os.getenv("HISTORY_MAX_SNAPSHOTS", "10"))
|
||||
# ============================================================
|
||||
|
||||
def process_input(state: AgentState) -> Dict:
|
||||
"""记录用户输入到对话历史,重置本轮请求状态。"""
|
||||
"""记录用户输入到对话历史,重置本轮请求状态。如有上次失败上下文则自动注入。"""
|
||||
user_input = state.get("user_input", "")
|
||||
|
||||
# 维护全量对话历史
|
||||
# 维护全量对话历史(始终记录原始用户消息)
|
||||
full_history = state.get("full_conversation_history", [])
|
||||
full_history.append({"role": "user", "content": user_input, "ts": _now_iso()})
|
||||
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.append({"role": "user", "content": user_input})
|
||||
@@ -402,6 +413,12 @@ def validate(state: AgentState) -> Dict:
|
||||
state["error_msg"] = "没有 JRXML 内容可供验证。"
|
||||
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)
|
||||
state["status"] = "pass" if result.get("valid") else "fail"
|
||||
state["error_msg"] = result.get("error", "")
|
||||
@@ -481,26 +498,47 @@ def correct_jrxml(state: AgentState) -> Dict:
|
||||
def finalize(state: AgentState) -> Dict:
|
||||
"""保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。"""
|
||||
jrxml = state.get("current_jrxml", "")
|
||||
state["final_jrxml"] = jrxml
|
||||
status = state.get("status", "")
|
||||
|
||||
if jrxml.strip():
|
||||
versions = state.get("jrxml_versions", [])
|
||||
if not isinstance(versions, list):
|
||||
versions = []
|
||||
intent = state.get("intent", "")
|
||||
label_map = {
|
||||
"initial_generation": "初始生成",
|
||||
"modify_report": "修改",
|
||||
"correct_jrxml": f"自动修正 (第{state.get('retry_count', 1)}次)",
|
||||
}
|
||||
versions.append({
|
||||
if status == "pass":
|
||||
state["final_jrxml"] = jrxml
|
||||
if jrxml.strip():
|
||||
versions = state.get("jrxml_versions", [])
|
||||
if not isinstance(versions, list):
|
||||
versions = []
|
||||
intent = state.get("intent", "")
|
||||
label_map = {
|
||||
"initial_generation": "初始生成",
|
||||
"modify_report": "修改",
|
||||
"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(),
|
||||
"jrxml": jrxml,
|
||||
"intent": intent,
|
||||
"label": label_map.get(intent, intent),
|
||||
"status": state.get("status", ""),
|
||||
}
|
||||
state["conversation_history"].append({
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n"
|
||||
f"错误: {error_msg}\n"
|
||||
f"请描述您想要的修改,系统会自动加载失败上下文继续修复。"
|
||||
),
|
||||
})
|
||||
state["jrxml_versions"] = versions
|
||||
return state
|
||||
|
||||
|
||||
@@ -510,7 +548,10 @@ def _extract_jrxml(text: str) -> str:
|
||||
xml_pattern = re.compile(r"```(?:xml|jrxml)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
m = xml_pattern.search(text)
|
||||
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)
|
||||
if jasper_tag:
|
||||
@@ -519,4 +560,11 @@ def _extract_jrxml(text: str) -> str:
|
||||
if text.startswith("<?xml") or text.startswith("<jasperReport"):
|
||||
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
|
||||
|
||||
@@ -37,3 +37,6 @@ class AgentState(TypedDict, total=False):
|
||||
|
||||
# 需求5:错误自增长(记录修正前的状态,供 validate 节点判断是否入知识库)
|
||||
last_error_case: dict
|
||||
|
||||
# 需求6:失败上下文传递 — 重试耗尽后暂存失败信息,下次用户输入时自动注入
|
||||
pending_failure_context: dict
|
||||
|
||||
@@ -138,18 +138,30 @@ def run_agent(user_input: str):
|
||||
agent_state["user_input"] = user_input
|
||||
agent_state["retry_count"] = 0
|
||||
|
||||
# ---- UI 容器 ----
|
||||
streaming_placeholder = st.empty() # 流式文本
|
||||
nodes_container = st.container() # 节点进度区
|
||||
# ---- UI 占位 ----
|
||||
progress_placeholder = st.empty() # 实时节点进度
|
||||
streaming_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_active = False
|
||||
current_stream_node = ""
|
||||
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:
|
||||
for event in st.session_state.graph.stream(
|
||||
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"):
|
||||
# 流式文本已在上面的 custom 事件中展示
|
||||
jrxml = node_state.get("current_jrxml", "")
|
||||
executed_nodes[-1]["detail"] = f"生成 {len(jrxml)} 字符 JRXML"
|
||||
|
||||
@@ -199,31 +210,29 @@ def run_agent(user_input: str):
|
||||
|
||||
final_state = node_state
|
||||
|
||||
# 每个节点完成后立即更新进度
|
||||
_render_progress(executed_nodes)
|
||||
|
||||
elif mode == "custom":
|
||||
cd = data
|
||||
if cd.get("type") == "stream":
|
||||
stream_text += cd.get("text", "")
|
||||
stream_active = True
|
||||
current_stream_node = cd.get("node", "")
|
||||
streaming_placeholder.code(stream_text, language="xml")
|
||||
|
||||
except Exception as e:
|
||||
progress_placeholder.empty()
|
||||
st.error(f"工作流异常: {e}")
|
||||
return
|
||||
|
||||
# ---- 渲染节点进度区 ----
|
||||
with nodes_container:
|
||||
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}")
|
||||
|
||||
# ---- 清除流式占位 ----
|
||||
# ---- 清理临时占位 ----
|
||||
progress_placeholder.empty()
|
||||
if stream_active:
|
||||
streaming_placeholder.empty()
|
||||
|
||||
# ---- 总结卡片 ----
|
||||
# 注:node_state 只含变更字段,用 agent_state(被所有节点就地修改)获取完整状态
|
||||
final_state = agent_state
|
||||
if final_state:
|
||||
st.session_state.agent_state = final_state
|
||||
intent = final_state.get("intent", "")
|
||||
@@ -239,7 +248,6 @@ def run_agent(user_input: str):
|
||||
|
||||
elif intent in ("undo_modification", "reset_session"):
|
||||
st.success("操作已完成")
|
||||
# 消息已在节点中添加
|
||||
|
||||
elif intent in ("preview_report", "export_pdf", "export_jrxml"):
|
||||
jrxml = final_state.get("current_jrxml", "")
|
||||
@@ -279,10 +287,10 @@ def run_agent(user_input: str):
|
||||
if jrxml:
|
||||
with st.expander("查看当前 JRXML"):
|
||||
_render_jrxml(jrxml, max_lines=80)
|
||||
st.caption("请简化报表结构后重试。")
|
||||
st.caption("💡 下次输入修改需求时,系统会自动加载失败上下文继续修复。")
|
||||
st.session_state.messages.append({
|
||||
"role": "assistant",
|
||||
"content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}",
|
||||
"content": f"❌ 经过 {retries} 次重试后仍无法生成有效的 JRXML。\n\n**错误:** {error_msg}\n\n💡 请直接描述修改需求,系统会自动加载失败上下文。",
|
||||
"type": "error_explanation",
|
||||
})
|
||||
else:
|
||||
@@ -431,6 +439,25 @@ with st.sidebar:
|
||||
else:
|
||||
# 新建模式:按 A4 模板处理
|
||||
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)
|
||||
|
||||
|
||||
+23
-4
@@ -65,17 +65,36 @@ def parse_file(file_path: str, file_type: str = "") -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_image(path: Path) -> dict:
|
||||
"""OCR 提取图片中的文字。"""
|
||||
"""OCR 提取图片中的文字。优先 EasyOCR,回退 PaddleOCR。"""
|
||||
try:
|
||||
img = PIL.Image.open(path)
|
||||
info = f"[图片: {img.size[0]}x{img.size[1]}, {img.mode}]"
|
||||
except Exception:
|
||||
info = "[图片: 无法读取元数据]"
|
||||
|
||||
# 尝试 PaddleOCR
|
||||
# 优先 EasyOCR(Windows 兼容性更好)
|
||||
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:
|
||||
from paddleocr import PaddleOCR
|
||||
ocr = PaddleOCR(lang="ch", use_angle_cls=False, show_log=False)
|
||||
ocr = PaddleOCR(lang="ch")
|
||||
result = ocr.ocr(str(path))
|
||||
lines = []
|
||||
if result and result[0]:
|
||||
@@ -97,7 +116,7 @@ def _parse_image(path: Path) -> dict:
|
||||
|
||||
# OCR 不可用 → 返回图片元信息 + 安装提示
|
||||
return {
|
||||
"text": f"{info}\n(如需 OCR 文字识别,请安装: pip install paddleocr)",
|
||||
"text": f"{info}\n(如需 OCR 文字识别,请安装: pip install easyocr)",
|
||||
"file_type": "image",
|
||||
"method": "metadata_only",
|
||||
"error": "OCR 引擎未安装,已返回图片元信息",
|
||||
|
||||
@@ -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]:
|
||||
"""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:
|
||||
from paddleocr import PaddleOCR
|
||||
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))
|
||||
|
||||
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"]))
|
||||
return elements
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -82,6 +82,35 @@ def _check_structural_issues(jrxml: str) -> list[str]:
|
||||
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]:
|
||||
"""根据 JasperReports XSD schema 验证 JRXML。"""
|
||||
if not SCHEMA_FILE.exists():
|
||||
@@ -111,6 +140,10 @@ async def validate_jrxml(req: ValidationRequest):
|
||||
if 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)
|
||||
if not valid:
|
||||
return ValidationResponse(valid=False, error=xsd_error)
|
||||
|
||||
Reference in New Issue
Block a user