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
+1
View File
@@ -228,4 +228,5 @@ def create_initial_state() -> AgentState:
history_states=[],
jrxml_versions=[],
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:
"""记录用户输入到对话历史,重置本轮请求状态。"""
"""记录用户输入到对话历史,重置本轮请求状态。如有上次失败上下文则自动注入。"""
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
+3
View File
@@ -37,3 +37,6 @@ class AgentState(TypedDict, total=False):
# 需求5:错误自增长(记录修正前的状态,供 validate 节点判断是否入知识库)
last_error_case: dict
# 需求6:失败上下文传递 — 重试耗尽后暂存失败信息,下次用户输入时自动注入
pending_failure_context: dict