feat: Streamlit多轮对话界面 + 集成测试

app.py:
  侧边栏:会话管理(创建/切换/删除)、快捷操作(预览/撤销/重置)、
         配置信息、JRXML下载
  主区域:多轮聊天、8种意图差异化展示(JRXML代码/咨询回答/
          错误解释/成功提示)
  URL参数:?session_id= 会话分享

tests/:
  test_validation.py: 验证服务6个单元测试(健康检查/空内容/
                      无效XML/缺少尺寸/有效JRXML/字段引用)
  test_agent.py: 5个集成验收场景(简单生成/自动修正/
                  多轮修改/上下文感知修改/最大重试处理)
This commit is contained in:
2026-05-14 23:21:22 +08:00
parent 4b43c5d3e4
commit e113374682
4 changed files with 558 additions and 0 deletions
View File
+135
View File
@@ -0,0 +1,135 @@
"""JRXML 代理集成测试 - 4 个验收场景。
这些测试模拟多轮对话并验证代理管道。
需要验证服务在 8001 端口上运行。
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from agent.graph import build_graph, create_initial_state
@pytest.fixture
def graph():
return build_graph()
def run_graph(graph, initial_state):
"""使用给定的初始状态运行图并返回最终状态。"""
final = None
for event in graph.stream(initial_state):
for node_name, node_state in event.items():
final = node_state
return final
class TestAcceptanceScenarios:
def test_scenario1_simple_report_generation(self, graph):
"""场景 1:生成简单的员工名册 - 应该通过验证。"""
state = create_initial_state()
state["user_input"] = (
"Generate an employee roster report with columns: employee_id (Integer), "
"full_name (String), department (String), and salary (BigDecimal). "
"Query from employees table. Include a title 'Employee Roster'."
)
state["stage"] = "initial_generation"
final = run_graph(graph, state)
assert final.get("current_jrxml"), "应该已生成 JRXML"
# 注意:通过/失败取决于 LLM 输出质量;我们检查是否得到了结果
print(f"场景 1 状态: {final.get('status')}, 错误: {final.get('error_msg', '')[:100]}")
def test_scenario2_auto_correction(self, graph):
"""场景 2:故意提出一个可能初次失败的需求。"""
state = create_initial_state()
state["user_input"] = (
"Create a sales summary report. Show customer_name and order_total. "
"Add a subtotal by customer group. Query from orders table."
)
state["stage"] = "initial_generation"
final = run_graph(graph, state)
assert final.get("retry_count", 0) <= 3, "不应超过最大重试次数"
print(f"场景 2 状态: {final.get('status')}, 重试次数: {final.get('retry_count', 0)}")
def test_scenario3_multi_turn_modification(self, graph):
"""场景 3:多轮对话 - 先生成,再修改两次。"""
# 第 1 轮:生成销售订单报表
state = create_initial_state()
state["user_input"] = (
"Create a sales order report with order_id (String), customer (String), "
"amount (BigDecimal), order_date (Date). Query from sales_orders."
)
state["stage"] = "initial_generation"
final = run_graph(graph, state)
print(f"第 1 轮状态: {final.get('status')}, 错误: {final.get('error_msg', '')[:100]}")
assert final.get("current_jrxml"), "第 1 轮应该已生成 JRXML"
# 第 2 轮:添加月度销售汇总
state2 = final.copy()
state2["user_input"] = "Add a monthly sales total summary in the summary band."
state2["user_modification_request"] = "Add a monthly sales total summary in the summary band."
state2["stage"] = "modification"
state2["retry_count"] = 0
final2 = run_graph(graph, state2)
print(f"第 2 轮状态: {final2.get('status')}")
assert final2.get("current_jrxml"), "第 2 轮应该已修改 JRXML"
# 第 3 轮:修改标题
state3 = final2.copy()
state3["user_input"] = "Change the title to '2024 Annual Sales Report' and make it bold."
state3["user_modification_request"] = "Change the title to '2024 Annual Sales Report' and make it bold."
state3["stage"] = "modification"
state3["retry_count"] = 0
final3 = run_graph(graph, state3)
print(f"第 3 轮状态: {final3.get('status')}")
jrxml = final3.get("current_jrxml", "")
assert "2024" in jrxml or "Annual" in jrxml, "标题修改应该体现在 JRXML 中"
def test_scenario4_context_aware_modification(self, graph):
"""场景 4:基于对话上下文的修改。"""
# 第 1 轮:生成按客户分组的报表
state = create_initial_state()
state["user_input"] = (
"Create a customer orders report grouped by customer_name with order_id, "
"order_total fields. Include a subtotal for each customer group. "
"Query from customer_orders."
)
state["stage"] = "initial_generation"
final = run_graph(graph, state)
print(f"第 1 轮状态: {final.get('status')}")
# 第 2 轮:上下文感知修改
state2 = final.copy()
state2["user_input"] = "Make the subtotal row font larger and bold."
state2["user_modification_request"] = "Make the subtotal row font larger and bold."
state2["stage"] = "modification"
state2["retry_count"] = 0
final2 = run_graph(graph, state2)
print(f"第 2 轮状态: {final2.get('status')}")
jrxml = final2.get("current_jrxml", "")
assert "isBold" in jrxml or "size=" in jrxml, "字体修改应该体现在结果中"
def test_max_retry_handling(self, graph):
"""测试在 MAX_RETRY 次失败后,图能否正常终止。"""
state = create_initial_state()
state["current_jrxml"] = "<invalid>xml<<<"
state["user_input"] = "Fix this"
state["retry_count"] = 3 # 已达到最大重试次数
state["status"] = "fail"
final = run_graph(graph, state)
assert final.get("retry_count", 0) >= 3 or final.get("status") == "pass"
+105
View File
@@ -0,0 +1,105 @@
"""JRXML 验证服务的单元测试。"""
import pytest
from fastapi.testclient import TestClient
from validation_service.main import app
client = TestClient(app)
class TestValidationService:
def test_health_endpoint(self):
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
def test_empty_jrxml(self):
resp = client.post("/validate", json={"jrxml": ""})
assert resp.status_code == 200
assert resp.json()["valid"] is False
assert "" in resp.json()["error"]
def test_invalid_xml(self):
resp = client.post("/validate", json={"jrxml": "<not>xml<<<"})
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
def test_missing_page_dimensions(self):
jrxml = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
name="TestReport" columnWidth="555"
leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20">
<queryString><![CDATA[SELECT * FROM test]]></queryString>
<field name="col1" class="java.lang.String"/>
<title><band height="30"/></title>
<detail>
<band height="20">
<textField>
<reportElement x="0" y="0" width="100" height="20"/>
<textFieldExpression><![CDATA[$F{col1}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>"""
resp = client.post("/validate", json={"jrxml": jrxml})
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "pageWidth" in data["error"]
def test_valid_jrxml(self):
jrxml = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
name="ValidReport" pageWidth="595" pageHeight="842" columnWidth="555"
leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20">
<queryString><![CDATA[SELECT emp_id, emp_name FROM employees]]></queryString>
<field name="emp_id" class="java.lang.Integer"/>
<field name="emp_name" class="java.lang.String"/>
<title><band height="30">
<staticText>
<reportElement x="0" y="0" width="555" height="30"/>
<text><![CDATA[Report Title]]></text>
</staticText>
</band></title>
<detail>
<band height="20">
<textField>
<reportElement x="0" y="0" width="100" height="20"/>
<textFieldExpression><![CDATA[$F{emp_id}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="110" y="0" width="200" height="20"/>
<textFieldExpression><![CDATA[$F{emp_name}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>"""
resp = client.post("/validate", json={"jrxml": jrxml})
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True, f"验证应该通过,实际错误: {data.get('error')}"
def test_missing_field_declaration(self):
jrxml = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports"
name="BadReport" pageWidth="595" pageHeight="842" columnWidth="555"
leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20">
<queryString><![CDATA[SELECT id FROM t]]></queryString>
<field name="id" class="java.lang.Integer"/>
<detail>
<band height="20">
<textField>
<reportElement x="0" y="0" width="100" height="20"/>
<textFieldExpression><![CDATA[$F{missing_field}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>"""
resp = client.post("/validate", json={"jrxml": jrxml})
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "missing_field" in data["error"]