feat: FastAPI+SSE API server, JRXML auto-reorder, session integrity fixes

This commit is contained in:
2026-05-22 17:53:59 +08:00
parent 1144a86d02
commit 1e5ce9725b
32 changed files with 9189 additions and 309 deletions
+201
View File
@@ -0,0 +1,201 @@
"""
JRXML 元素自动排序 — 按 JasperReports XSD <xs:sequence> 要求重排子元素。
XSD 要求 jasperReport 子元素严格按以下顺序:
property, propertyExpression, import, template, reportFont,
style, subDataset, scriptlet, parameter, queryString, field,
sortField, variable, filterExpression, group, background, title,
pageHeader, columnHeader, detail, columnFooter, pageFooter,
lastPageFooter, summary, noData
以及 band 内部的 reportElement 必须在其他元素之前。
"""
import re
import xml.etree.ElementTree as ET
from typing import Optional
# JasperReports XSD sequence 顺序(索引越小越靠前)
JASPERREPORT_ORDER = {
"property": 0,
"propertyExpression": 1,
"import": 2,
"template": 3,
"reportFont": 4,
"style": 5,
"subDataset": 6,
"scriptlet": 7,
"parameter": 8,
"queryString": 9,
"field": 10,
"sortField": 11,
"variable": 12,
"filterExpression": 13,
"group": 14,
"background": 15,
"title": 16,
"pageHeader": 17,
"columnHeader": 18,
"detail": 19,
"columnFooter": 20,
"pageFooter": 21,
"lastPageFooter": 22,
"summary": 23,
"noData": 24,
}
# 带命名空间的标签映射(去掉 ns 前缀后匹配)
NS = "http://jasperreports.sourceforge.net/jasperreports"
def _tag_local(tag: str) -> str:
"""提取标签本地名(去掉命名空间前缀)。"""
return tag.split("}")[-1] if "}" in tag else tag
def _sort_key(elem: ET.Element) -> int:
"""排序键:按 JASPERREPORT_ORDER 中的顺序,未知元素放最后。"""
local = _tag_local(elem.tag)
return JASPERREPORT_ORDER.get(local, 999)
def reorder_jrxml_elements(xml_string: str) -> str:
"""重排 JRXML 字符串中的子元素顺序,使其符合 XSD sequence 要求。
处理范围:
- jasperReport 的直接子元素
- band 的直接子元素(reportElement 在前)
返回重排后的 XML 字符串。如果解析失败,返回原始字符串。
"""
try:
root = ET.fromstring(xml_string)
except ET.ParseError:
return xml_string # 无法解析,返回原始
_reorder_children(root)
_reorder_bands(root)
# 序列化回字符串
result = ET.tostring(root, encoding="unicode")
# 恢复 XML 声明、CDATA、命名空间
result = _restore_formatting(xml_string, result)
return result
def _reorder_children(parent: ET.Element):
"""递归重排所有子元素。"""
children = list(parent)
if not children:
return
# 按 XSD 顺序排序
children.sort(key=_sort_key)
# 重建子元素列表
for i, child in enumerate(children):
# ET 不支持直接 reorder,用 remove + insert
pass
# 实际上 ElementTree 不支持直接重排,需要重建
# 我们用更可靠的方式:收集所有子元素,清空,再按顺序添加
sorted_children = sorted(list(parent), key=_sort_key)
# 移除所有子元素
for child in list(parent):
parent.remove(child)
# 按排序后的顺序重新添加(保持 tail 文本在最后)
tail_text = ""
for child in sorted_children:
tail_text = child.tail or ""
child.tail = ""
parent.append(child)
# 恢复最后一个元素的 tail
if sorted_children and tail_text:
sorted_children[-1].tail = tail_text
# 递归处理子元素
for child in parent:
_reorder_children(child)
def _reorder_bands(root: ET.Element):
"""确保 band 内部 reportElement 在其他元素之前。"""
for elem in root.iter():
if _tag_local(elem.tag) == "band":
_ensure_reportelement_first(elem)
def _ensure_reportelement_first(band: ET.Element):
"""在 band 内部,确保 reportElement 元素排在最前面。"""
children = list(band)
report_elements = [c for c in children if _tag_local(c.tag) == "reportElement"]
other_elements = [c for c in children if _tag_local(c.tag) != "reportElement"]
if not report_elements:
return
# 移除所有
for c in list(band):
band.remove(c)
# 先添加 reportElement
tail = ""
for r in report_elements:
r.tail = ""
band.append(r)
# 再添加其他
for o in other_elements:
o.tail = ""
band.append(o)
# 恢复 tail
last = band[-1] if list(band) else None
if last and children:
last.tail = children[-1].tail or ""
def _restore_formatting(original: str, reordered: str) -> str:
"""恢复 XML 声明和 CDATA 段。"""
# 保留原始声明
decl = ""
if original.strip().startswith("<?xml"):
m = re.match(r'<\?xml[^?]*\?>', original)
if m:
decl = m.group()
if decl and not reordered.strip().startswith("<?xml"):
reordered = decl + "\n" + reordered
# 恢复 CDATAET 会把 CDATA 转成普通文本)
# 从原始 XML 提取所有 CDATA 块
cdata_pattern = re.compile(r'<!\[CDATA\[(.*?)\]\]>', re.DOTALL)
cdata_blocks = cdata_pattern.findall(original)
if cdata_blocks:
# 在重排后的 XML 中,对应位置的文本用 CDATA 包裹
def _restore_cdata(match):
nonlocal cdata_blocks
text = match.group(1)
for cdata in cdata_blocks:
if cdata.strip() == text.strip():
return f"<![CDATA[{cdata}]]>"
return match.group(0)
# 替换已转义的文本为 CDATA
reordered = re.sub(
r'(<queryString[^>]*>)\s*(.*?)\s*(</queryString>)',
lambda m: m.group(1) + f"\n <![CDATA[{m.group(2).strip()}]]>\n " + m.group(3),
reordered,
flags=re.DOTALL
)
return reordered
def normalize_jrxml(jrxml_text: str) -> str:
"""规范化 JRXML:排序元素 + 恢复格式。"""
if not jrxml_text or not jrxml_text.strip():
return jrxml_text
result = reorder_jrxml_elements(jrxml_text)
return result
+2 -1
View File
@@ -179,7 +179,8 @@ def _build_raw_llm(caller: str = "") -> tuple[_BaseLLM, str, str]:
messages=[{"role": "user", "content": [{"type": "text", "text": prompt}]}],
)
for block in resp.content:
if block.type == "text":
block_type = getattr(block, "type", "")
if block_type == "text":
return type("Response", (), {"content": block.text})()
return type("Response", (), {"content": ""})()
+2
View File
@@ -90,6 +90,8 @@ def save_session(session_id: str, agent_state: dict, session_name: str = ""):
)
try:
json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp.flush()
os.fsync(tmp.fileno())
tmp.close()
os.replace(tmp.name, str(fp))
except Exception:
+1 -1
View File
@@ -7,7 +7,7 @@ from dotenv import load_dotenv
from backend.logger import get_logger
load_dotenv()
load_dotenv(override=True)
_val_log = get_logger("validation")