feat: comprehensive v2 upgrade — streaming, error KB, file upload, layout analysis
Major changes: - Streaming: LLM统一 _BaseLLM 接口 (invoke + stream), generate/modify/correct 节点使用 get_stream_writer() 实现逐字输出, UI 节点平铺展开自动折叠 - Prompt外部化: 7个prompt拆分到 prompts/*.md, loader.py 支持热重载 - 错误自增长: backend/error_kb.py — 指纹去重 + ChromaDB持久化, correct_jrxml→validate 通过时自动入库, retrieve同时搜索错误KB - 文件上传: backend/file_parser.py — PDF/DOCX/图片/文本解析, 侧边栏多文件上传, 文本自动注入下一条消息 - A4模板识别: backend/layout_analyzer.py — 三种模式(完整A4/行片段修改/行片段新建), PaddleOCR元素提取 + 行分组 + JRXML section匹配 - 会话历史下载: jrxml_versions版本追踪 + 侧边栏历史版本下载按钮 - 预览修复: route_after_save跳过预览/导出意图的验证循环 - Ctrl+C修复: JS注入拦截Streamlit裸c键清缓存 Docs: CLAUDE.md (完整项目文档), ROADMAP.md (改进路线图) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+94
-129
@@ -12,6 +12,7 @@ from dotenv import load_dotenv
|
||||
from agent.state import AgentState
|
||||
from backend.llm import get_llm
|
||||
from backend.validation import validate_jrxml
|
||||
from prompts.loader import load_prompt
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -20,119 +21,6 @@ CONTEXT_MAX_TOKENS = int(os.getenv("CONTEXT_MAX_TOKENS", "6000"))
|
||||
CONTEXT_KEEP_RECENT = int(os.getenv("CONTEXT_KEEP_RECENT", "4"))
|
||||
HISTORY_MAX_SNAPSHOTS = int(os.getenv("HISTORY_MAX_SNAPSHOTS", "10"))
|
||||
|
||||
# ============================================================
|
||||
# 意图分类提示词(约 180 tokens,控制在 200 token 以内)
|
||||
# ============================================================
|
||||
INTENT_CLASSIFY_PROMPT = """你是意图分类器。根据用户输入判断意图,只输出意图名称。
|
||||
|
||||
当前有报表:{has_report}
|
||||
用户输入:{user_input}
|
||||
|
||||
可选意图:
|
||||
- initial_generation(新建报表,或无报表时的任何需求)
|
||||
- modify_report(修改当前已有报表)
|
||||
- preview_report(预览/查看当前报表)
|
||||
- export_pdf(导出PDF文件)
|
||||
- export_jrxml(下载/导出/保存JRXML文件)
|
||||
- undo_modification(撤销/回退上一步修改)
|
||||
- consult_question(咨询JasperReports相关知识或使用问题)
|
||||
- reset_session(清空/重置/重新开始)
|
||||
|
||||
意图名称:"""
|
||||
|
||||
# ============================================================
|
||||
# 咨询回答提示词
|
||||
# ============================================================
|
||||
CONSULT_PROMPT = """你是 JasperReports 专家。用简洁清晰的中文回答用户关于 JasperReports 的问题。
|
||||
|
||||
用户问题:{question}
|
||||
|
||||
直接回答:"""
|
||||
|
||||
# ============================================================
|
||||
# 原有提示词(不变)
|
||||
# ============================================================
|
||||
INITIAL_GENERATION_PROMPT = """你是一位资深 JasperReports 工程师。根据以下参考模板和用户需求,生成一个完整、可编译的 JRXML 文件。
|
||||
JRXML 必须兼容 JasperReports 7.0.6 schema。
|
||||
|
||||
关键规则:
|
||||
- 只输出 JRXML 代码,不要解释,不要 markdown 标记。
|
||||
- 报表正文中使用的每个字段必须在 <field name="..."> 部分中声明。
|
||||
- 根元素为 <jasperReport>,包含正确的 xmlns 属性。
|
||||
- 包含 <queryString>,在 <![CDATA[...]]> 中包含 SQL 查询。
|
||||
- 确保所有交叉引用(字段名称、band 元素)保持一致。
|
||||
|
||||
参考模板和组件:
|
||||
{context}
|
||||
|
||||
用户需求:
|
||||
{user_request}
|
||||
"""
|
||||
|
||||
MODIFICATION_PROMPT = """你是一位资深 JasperReports 工程师。用户想要修改一个现有的、可编译的 JRXML 报表。精确应用请求的更改到当前 JRXML 并输出完整修改后的 JRXML。
|
||||
|
||||
关键规则:
|
||||
- 只输出完整修改后的 JRXML 代码,不要解释,不要 markdown 标记。
|
||||
- 保留所有未被更改的现有结构。
|
||||
- 结果必须继续与 JasperReports 7.0.6 兼容。
|
||||
- 报表正文中使用的每个字段必须在 <field> 部分中声明。
|
||||
- 如果添加新字段,正确声明它们。
|
||||
- 确保 <queryString> 是 <![CDATA[...]]> 中有效的 SQL。
|
||||
|
||||
当前 JRXML:
|
||||
{current_jrxml}
|
||||
|
||||
对话历史:
|
||||
{conversation_history}
|
||||
|
||||
用户的修改请求:
|
||||
{modification_request}
|
||||
"""
|
||||
|
||||
CORRECTION_PROMPT = """你是一位资深 JasperReports 工程师。你生成的 JRXML 文件编译失败。分析错误并修复 JRXML。
|
||||
|
||||
关键规则:
|
||||
- 只输出完整修复后的 JRXML 代码,不要解释,不要 markdown 标记。
|
||||
- JRXML 必须与 JasperReports 7.0.6 兼容。
|
||||
- 解决下面列出的特定错误。
|
||||
|
||||
当前 JRXML(带错误):
|
||||
{current_jrxml}
|
||||
|
||||
编译错误:
|
||||
{error_msg}
|
||||
|
||||
错误的自然语言解释:
|
||||
{explanation}
|
||||
|
||||
立即生成修正后的 JRXML:
|
||||
"""
|
||||
|
||||
EXPLAIN_PROMPT = """你是一位 JasperReports 专家。用普通非技术语言解释以下 JRXML 编译错误,让业务用户能够理解。
|
||||
|
||||
错误消息:
|
||||
{error_msg}
|
||||
|
||||
当前 JRXML 片段(前 80 行):
|
||||
{jrxml_snippet}
|
||||
|
||||
用 2-3 句话解释哪里出了问题以及如何修复:
|
||||
"""
|
||||
|
||||
COMPRESSION_PROMPT = """你是一个信息压缩助手。以下是用户与报表生成助手之间的历史对话记录,请将其压缩为一份简洁的摘要(不超过200字)。
|
||||
|
||||
摘要必须保留以下关键信息:
|
||||
- 用户提出的所有报表需求点(字段、标题、分组、汇总等)
|
||||
- 用户提出的所有修改要求及其顺序
|
||||
- 当前报表的核心结构(字段列表、标题、分组方式)
|
||||
- 任何特殊要求或约束条件
|
||||
|
||||
只输出摘要文本,不要添加任何解释或标记。
|
||||
|
||||
对话记录:
|
||||
{conversation_text}
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心工作流节点
|
||||
@@ -191,7 +79,7 @@ def classify_intent(state: AgentState) -> Dict:
|
||||
intent = "initial_generation"
|
||||
try:
|
||||
llm = get_llm()
|
||||
prompt = INTENT_CLASSIFY_PROMPT.format(
|
||||
prompt = load_prompt("intent_classify").format(
|
||||
has_report=has_report,
|
||||
user_input=user_input[:500],
|
||||
)
|
||||
@@ -222,7 +110,7 @@ def handle_consult(state: AgentState) -> Dict:
|
||||
user_input = state.get("user_input", "")
|
||||
try:
|
||||
llm = get_llm()
|
||||
prompt = CONSULT_PROMPT.format(question=user_input)
|
||||
prompt = load_prompt("consult").format(question=user_input)
|
||||
resp = llm.invoke(prompt)
|
||||
answer = resp.content.strip()
|
||||
except Exception:
|
||||
@@ -332,7 +220,7 @@ def manage_context(state: AgentState) -> Dict:
|
||||
|
||||
try:
|
||||
llm = get_llm()
|
||||
prompt = COMPRESSION_PROMPT.format(conversation_text=conv_text)
|
||||
prompt = load_prompt("compression").format(conversation_text=conv_text)
|
||||
resp = llm.invoke(prompt)
|
||||
new_compressed = resp.content.strip()[:300]
|
||||
except Exception:
|
||||
@@ -421,12 +309,21 @@ def _now_iso() -> str:
|
||||
|
||||
|
||||
def retrieve(state: AgentState) -> Dict:
|
||||
"""在 Chroma 中搜索相关的 JRXML 模板和组件(使用 rag_jrxml 语义分块管线)。"""
|
||||
"""在 ChromaDB + 错误知识库中搜索相关的 JRXML 模板和组件。"""
|
||||
try:
|
||||
from backend.rag_adapter import search_chunks
|
||||
from backend.error_kb import search_error_cases
|
||||
|
||||
user_input = state.get("user_input", "")
|
||||
context = search_chunks(user_input, k=5)
|
||||
|
||||
# 如果有最近错误,同时搜索错误知识库
|
||||
error_msg = state.get("error_msg", "")
|
||||
if error_msg:
|
||||
error_context = search_error_cases(error_msg, k=2)
|
||||
if error_context:
|
||||
context = f"{context}\n\n[历史错误修正案例]\n{error_context}"
|
||||
|
||||
state["retrieved_context"] = context
|
||||
except Exception:
|
||||
state["retrieved_context"] = ""
|
||||
@@ -435,13 +332,19 @@ def retrieve(state: AgentState) -> Dict:
|
||||
|
||||
def generate(state: AgentState) -> Dict:
|
||||
"""根据用户需求和检索到的上下文生成初始 JRXML。"""
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
writer = get_stream_writer()
|
||||
llm = get_llm()
|
||||
prompt = INITIAL_GENERATION_PROMPT.format(
|
||||
prompt = load_prompt("initial_generation").format(
|
||||
context=state.get("retrieved_context", ""),
|
||||
user_request=state.get("user_input", ""),
|
||||
)
|
||||
resp = llm.invoke(prompt)
|
||||
jrxml = _extract_jrxml(resp.content)
|
||||
full = []
|
||||
for chunk in llm.stream(prompt):
|
||||
full.append(chunk)
|
||||
writer({"type": "stream", "node": "generate", "text": chunk})
|
||||
jrxml = _extract_jrxml("".join(full))
|
||||
state["current_jrxml"] = jrxml
|
||||
state["conversation_history"].append({"role": "assistant", "content": jrxml})
|
||||
return state
|
||||
@@ -449,6 +352,9 @@ def generate(state: AgentState) -> Dict:
|
||||
|
||||
def modify_jrxml(state: AgentState) -> Dict:
|
||||
"""根据用户的修改请求修改现有 JRXML。"""
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
writer = get_stream_writer()
|
||||
llm = get_llm()
|
||||
# 构建对话上下文:压缩摘要 + 最近对话
|
||||
compressed = state.get("compressed_history", "")
|
||||
@@ -459,13 +365,16 @@ def modify_jrxml(state: AgentState) -> Dict:
|
||||
conv_parts.append(json.dumps(recent, ensure_ascii=False, indent=2))
|
||||
conv_text = "\n\n---\n\n".join(conv_parts)
|
||||
|
||||
prompt = MODIFICATION_PROMPT.format(
|
||||
prompt = load_prompt("modification").format(
|
||||
current_jrxml=state.get("current_jrxml", ""),
|
||||
conversation_history=conv_text,
|
||||
modification_request=state.get("user_modification_request", ""),
|
||||
)
|
||||
resp = llm.invoke(prompt)
|
||||
jrxml = _extract_jrxml(resp.content)
|
||||
full = []
|
||||
for chunk in llm.stream(prompt):
|
||||
full.append(chunk)
|
||||
writer({"type": "stream", "node": "modify_jrxml", "text": chunk})
|
||||
jrxml = _extract_jrxml("".join(full))
|
||||
state["current_jrxml"] = jrxml
|
||||
state["conversation_history"].append(
|
||||
{
|
||||
@@ -496,6 +405,29 @@ def validate(state: AgentState) -> Dict:
|
||||
result = validate_jrxml(jrxml)
|
||||
state["status"] = "pass" if result.get("valid") else "fail"
|
||||
state["error_msg"] = result.get("error", "")
|
||||
|
||||
# 修正成功后记录到错误知识库
|
||||
if result.get("valid") and state.get("retry_count", 0) > 0:
|
||||
case = state.get("last_error_case", {})
|
||||
if case and case.get("error_msg"):
|
||||
try:
|
||||
from backend.error_kb import record_error
|
||||
|
||||
recorded = record_error(
|
||||
error_msg=case["error_msg"],
|
||||
bad_jrxml=case.get("bad_jrxml", ""),
|
||||
good_jrxml=jrxml,
|
||||
correction_prompt=case.get("correction_prompt", ""),
|
||||
retry_count=state.get("retry_count", 0),
|
||||
)
|
||||
if recorded:
|
||||
state["conversation_history"].append({
|
||||
"role": "system",
|
||||
"content": f"[系统] 错误案例已记录到知识库(指纹: {case['error_msg'][:40]}...)",
|
||||
})
|
||||
except Exception:
|
||||
pass # 知识库写入不影响主流程
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@@ -506,7 +438,7 @@ def explain_error(state: AgentState) -> Dict:
|
||||
lines = jrxml.split("\n")[:80]
|
||||
snippet = "\n".join(lines)
|
||||
|
||||
prompt = EXPLAIN_PROMPT.format(
|
||||
prompt = load_prompt("explain_error").format(
|
||||
error_msg=state.get("error_msg", "未知错误"),
|
||||
jrxml_snippet=snippet,
|
||||
)
|
||||
@@ -517,14 +449,27 @@ def explain_error(state: AgentState) -> Dict:
|
||||
|
||||
def correct_jrxml(state: AgentState) -> Dict:
|
||||
"""尝试自动修正验证失败的 JRXML。"""
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
writer = get_stream_writer()
|
||||
llm = get_llm()
|
||||
prompt = CORRECTION_PROMPT.format(
|
||||
prompt = load_prompt("correction").format(
|
||||
current_jrxml=state.get("current_jrxml", ""),
|
||||
error_msg=state.get("error_msg", ""),
|
||||
explanation=state.get("natural_explanation", ""),
|
||||
)
|
||||
resp = llm.invoke(prompt)
|
||||
jrxml = _extract_jrxml(resp.content)
|
||||
# 保存修正前状态(供 validate 判断是否写入错误知识库)
|
||||
state["last_error_case"] = {
|
||||
"error_msg": state.get("error_msg", ""),
|
||||
"bad_jrxml": state.get("current_jrxml", ""),
|
||||
"correction_prompt": prompt,
|
||||
}
|
||||
|
||||
full = []
|
||||
for chunk in llm.stream(prompt):
|
||||
full.append(chunk)
|
||||
writer({"type": "stream", "node": "correct_jrxml", "text": chunk})
|
||||
jrxml = _extract_jrxml("".join(full))
|
||||
state["current_jrxml"] = jrxml
|
||||
state["retry_count"] = state.get("retry_count", 0) + 1
|
||||
state["conversation_history"].append(
|
||||
@@ -534,8 +479,28 @@ def correct_jrxml(state: AgentState) -> Dict:
|
||||
|
||||
|
||||
def finalize(state: AgentState) -> Dict:
|
||||
"""保存最终验证通过的 JRXML 并更新对话历史。"""
|
||||
state["final_jrxml"] = state.get("current_jrxml", "")
|
||||
"""保存最终验证通过的 JRXML 并更新对话历史 + 版本记录。"""
|
||||
jrxml = state.get("current_jrxml", "")
|
||||
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": state.get("status", ""),
|
||||
})
|
||||
state["jrxml_versions"] = versions
|
||||
return state
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user