316 lines
13 KiB
Python
316 lines
13 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
ReportGenerationPanel - Word 分析报告生成面板
|
||
"""
|
||
|
||
import os
|
||
import traceback
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||
from PyQt5.QtWidgets import (
|
||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
||
QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox,
|
||
QMessageBox, QFileDialog,
|
||
)
|
||
|
||
from src.gui.styles import ModernStylesheet
|
||
|
||
|
||
class ReportGenerateThread(QThread):
|
||
"""后台生成 Word 报告(避免阻塞 UI)。"""
|
||
finished_ok = pyqtSignal(str)
|
||
failed = pyqtSignal(str)
|
||
log_message = pyqtSignal(str, str)
|
||
|
||
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict):
|
||
super().__init__()
|
||
self.work_dir = work_dir
|
||
self.output_dir = output_dir
|
||
self.report_title = report_title
|
||
self.options = options
|
||
|
||
def run(self):
|
||
try:
|
||
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
|
||
|
||
url = (self.options.get("ollama_url") or "").strip() or None
|
||
vision = (self.options.get("ollama_vision_model") or "").strip() or None
|
||
text = (self.options.get("ollama_text_model") or "").strip() or None
|
||
if self.options.get("text_same_as_vision"):
|
||
text = vision
|
||
timeout = self.options.get("ollama_timeout_s")
|
||
enable_ai = self.options.get("enable_ai_analysis")
|
||
|
||
ai_cfg = ReportGenerationConfig(
|
||
ollama_base_url=url,
|
||
ollama_vision_model=vision,
|
||
ollama_text_model=text,
|
||
ollama_timeout_s=int(timeout) if timeout is not None else None,
|
||
enable_ai_analysis=bool(enable_ai),
|
||
)
|
||
self.log_message.emit(
|
||
f"报告生成:工作目录={self.work_dir},AI={'开' if enable_ai else '关'},"
|
||
f"模型URL={url or '(环境变量 OLLAMA_URL)'}",
|
||
"info",
|
||
)
|
||
gen = WaterQualityReportGenerator(
|
||
work_dir=self.work_dir,
|
||
output_dir=self.output_dir,
|
||
ai_config=ai_cfg,
|
||
)
|
||
out_path = gen.generate_report(
|
||
work_dir=self.work_dir,
|
||
report_title=self.report_title or "水质参数反演分析报告",
|
||
)
|
||
self.finished_ok.emit(str(out_path))
|
||
except Exception as e:
|
||
self.failed.emit(f"{e}\n{traceback.format_exc()}")
|
||
|
||
|
||
class ReportGenerationPanel(QWidget):
|
||
"""Word 报告生成:工作目录、输出目录、Ollama URL/模型、是否启用 AI 等。"""
|
||
|
||
def __init__(self, main_window=None, parent=None):
|
||
super().__init__(parent)
|
||
self.main_window = main_window
|
||
self._report_thread = None
|
||
self.init_ui()
|
||
|
||
def init_ui(self):
|
||
layout = QVBoxLayout()
|
||
layout.setContentsMargins(10, 10, 10, 10)
|
||
layout.setSpacing(10)
|
||
|
||
intro = QLabel(
|
||
"根据工作目录下的可视化结果(14_visualization 等)生成 Word 分析报告。"
|
||
"需已存在可视化图表;AI 分析通过 Ollama /api/chat 调用本地或远程服务。"
|
||
)
|
||
intro.setWordWrap(True)
|
||
intro.setStyleSheet(
|
||
f"color: {ModernStylesheet.COLORS.get('text_secondary', '#666')};"
|
||
)
|
||
layout.addWidget(intro)
|
||
|
||
path_group = QGroupBox("路径")
|
||
path_form = QFormLayout()
|
||
|
||
wd_row = QHBoxLayout()
|
||
self.work_dir_edit = QLineEdit()
|
||
self.work_dir_edit.setPlaceholderText("选择流程工作目录(含 14_visualization)…")
|
||
wd_browse = QPushButton("浏览…")
|
||
wd_browse.clicked.connect(self.browse_work_dir)
|
||
sync_btn = QPushButton("同步主窗口工作目录")
|
||
sync_btn.clicked.connect(self.sync_work_dir_from_main)
|
||
wd_row.addWidget(self.work_dir_edit, 1)
|
||
wd_row.addWidget(wd_browse)
|
||
wd_row.addWidget(sync_btn)
|
||
path_form.addRow("工作目录:", wd_row)
|
||
|
||
out_row = QHBoxLayout()
|
||
self.output_dir_edit = QLineEdit()
|
||
self.output_dir_edit.setPlaceholderText("留空则保存到 工作目录/14_visualization")
|
||
out_browse = QPushButton("浏览…")
|
||
out_browse.clicked.connect(self.browse_output_dir)
|
||
out_row.addWidget(self.output_dir_edit, 1)
|
||
out_row.addWidget(out_browse)
|
||
path_form.addRow("报告输出目录:", out_row)
|
||
|
||
self.report_title_edit = QLineEdit()
|
||
self.report_title_edit.setText("水质参数反演分析报告")
|
||
path_form.addRow("报告标题:", self.report_title_edit)
|
||
|
||
path_group.setLayout(path_form)
|
||
layout.addWidget(path_group)
|
||
|
||
ai_group = QGroupBox("AI 分析(Ollama)")
|
||
ai_form = QFormLayout()
|
||
|
||
self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
|
||
self.enable_ai_cb.setChecked(
|
||
os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}
|
||
)
|
||
ai_form.addRow(self.enable_ai_cb)
|
||
|
||
self.ollama_url_edit = QLineEdit()
|
||
self.ollama_url_edit.setText(
|
||
os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
|
||
)
|
||
ai_form.addRow("服务 URL:", self.ollama_url_edit)
|
||
|
||
self.vision_model_edit = QLineEdit()
|
||
self.vision_model_edit.setText(
|
||
os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b")
|
||
)
|
||
ai_form.addRow("视觉模型:", self.vision_model_edit)
|
||
|
||
self.same_text_model_cb = QCheckBox("文本总结与视觉使用同一模型")
|
||
self.same_text_model_cb.setChecked(True)
|
||
ai_form.addRow(self.same_text_model_cb)
|
||
|
||
self.text_model_edit = QLineEdit()
|
||
self.text_model_edit.setText(
|
||
os.environ.get(
|
||
"OLLAMA_TEXT_MODEL",
|
||
self.vision_model_edit.text() or "qwen3-vl:8b"
|
||
)
|
||
)
|
||
self.text_model_edit.setEnabled(False)
|
||
self.same_text_model_cb.toggled.connect(self._on_same_text_toggled)
|
||
self.vision_model_edit.textChanged.connect(self._sync_text_model_if_linked)
|
||
ai_form.addRow("文本模型:", self.text_model_edit)
|
||
|
||
self.timeout_spin = QSpinBox()
|
||
self.timeout_spin.setRange(30, 3600)
|
||
self.timeout_spin.setSingleStep(30)
|
||
self.timeout_spin.setValue(int(os.environ.get("OLLAMA_TIMEOUT_S", "120")))
|
||
ai_form.addRow("请求超时(秒):", self.timeout_spin)
|
||
|
||
ai_group.setLayout(ai_form)
|
||
layout.addWidget(ai_group)
|
||
|
||
btn_row = QHBoxLayout()
|
||
self.generate_btn = QPushButton("生成 Word 报告")
|
||
self.generate_btn.setStyleSheet(
|
||
ModernStylesheet.get_button_stylesheet("success")
|
||
)
|
||
self.generate_btn.clicked.connect(self.on_generate_clicked)
|
||
btn_row.addWidget(self.generate_btn)
|
||
btn_row.addStretch()
|
||
layout.addLayout(btn_row)
|
||
|
||
layout.addStretch()
|
||
self.setLayout(layout)
|
||
|
||
def _on_same_text_toggled(self, checked: bool):
|
||
self.text_model_edit.setEnabled(not checked)
|
||
if checked:
|
||
self.text_model_edit.setText(self.vision_model_edit.text())
|
||
|
||
def _sync_text_model_if_linked(self, _t=None):
|
||
if self.same_text_model_cb.isChecked():
|
||
self.text_model_edit.blockSignals(True)
|
||
self.text_model_edit.setText(self.vision_model_edit.text())
|
||
self.text_model_edit.blockSignals(False)
|
||
|
||
def _get_default_work_dir(self):
|
||
"""获取 work_dir,优先用主窗口缓存的 work_dir"""
|
||
if self.main_window and hasattr(self.main_window, 'work_dir') and self.main_window.work_dir:
|
||
return str(self.main_window.work_dir)
|
||
return ""
|
||
|
||
def browse_work_dir(self):
|
||
default = self._get_default_work_dir()
|
||
d = QFileDialog.getExistingDirectory(self, "选择工作目录", default)
|
||
if d:
|
||
self.work_dir_edit.setText(d)
|
||
|
||
def browse_output_dir(self):
|
||
default = self._get_default_work_dir()
|
||
if default:
|
||
default = os.path.join(default, "14_visualization")
|
||
d = QFileDialog.getExistingDirectory(self, "选择报告输出目录", default)
|
||
if d:
|
||
self.output_dir_edit.setText(d)
|
||
|
||
def sync_work_dir_from_main(self):
|
||
mw = self.main_window
|
||
if mw is not None and getattr(mw, "work_dir", None):
|
||
self.work_dir_edit.setText(str(mw.work_dir))
|
||
else:
|
||
QMessageBox.information(self, "提示", "主窗口尚未设置工作目录。")
|
||
|
||
def set_work_dir(self, work_dir):
|
||
if work_dir:
|
||
self.work_dir_edit.setText(str(work_dir))
|
||
|
||
def get_config(self):
|
||
return {
|
||
"work_dir": self.work_dir_edit.text().strip() or None,
|
||
"output_dir": self.output_dir_edit.text().strip() or None,
|
||
"report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告",
|
||
"ollama_url": self.ollama_url_edit.text().strip(),
|
||
"ollama_vision_model": self.vision_model_edit.text().strip(),
|
||
"ollama_text_model": self.text_model_edit.text().strip(),
|
||
"text_same_as_vision": self.same_text_model_cb.isChecked(),
|
||
"ollama_timeout_s": self.timeout_spin.value(),
|
||
"enable_ai_analysis": self.enable_ai_cb.isChecked(),
|
||
}
|
||
|
||
def set_config(self, config):
|
||
if not config:
|
||
return
|
||
if config.get("work_dir"):
|
||
self.work_dir_edit.setText(str(config["work_dir"]))
|
||
if "output_dir" in config:
|
||
self.output_dir_edit.setText(str(config["output_dir"] or ""))
|
||
if config.get("report_title"):
|
||
self.report_title_edit.setText(str(config["report_title"]))
|
||
if config.get("ollama_url"):
|
||
self.ollama_url_edit.setText(str(config["ollama_url"]))
|
||
if config.get("ollama_vision_model"):
|
||
self.vision_model_edit.setText(str(config["ollama_vision_model"]))
|
||
if "text_same_as_vision" in config:
|
||
self.same_text_model_cb.setChecked(bool(config["text_same_as_vision"]))
|
||
if config.get("ollama_text_model"):
|
||
self.text_model_edit.setText(str(config["ollama_text_model"]))
|
||
if config.get("ollama_timeout_s") is not None:
|
||
self.timeout_spin.setValue(int(config["ollama_timeout_s"]))
|
||
if "enable_ai_analysis" in config:
|
||
self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
|
||
|
||
def on_generate_clicked(self):
|
||
wd = self.work_dir_edit.text().strip()
|
||
if not wd or not os.path.isdir(wd):
|
||
QMessageBox.warning(self, "提示", "请选择有效的工作目录。")
|
||
return
|
||
viz = Path(wd) / "14_visualization"
|
||
if not viz.is_dir():
|
||
QMessageBox.warning(
|
||
self,
|
||
"提示",
|
||
f"未找到可视化目录:\n{viz}\n请先完成流程或生成可视化。",
|
||
)
|
||
return
|
||
if self._report_thread and self._report_thread.isRunning():
|
||
QMessageBox.information(self, "提示", "报告正在生成中,请稍候。")
|
||
return
|
||
|
||
out = self.output_dir_edit.text().strip() or None
|
||
title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
|
||
opts = {
|
||
"ollama_url": self.ollama_url_edit.text().strip(),
|
||
"ollama_vision_model": self.vision_model_edit.text().strip(),
|
||
"ollama_text_model": self.text_model_edit.text().strip(),
|
||
"text_same_as_vision": self.same_text_model_cb.isChecked(),
|
||
"ollama_timeout_s": self.timeout_spin.value(),
|
||
"enable_ai_analysis": self.enable_ai_cb.isChecked(),
|
||
}
|
||
self.generate_btn.setEnabled(False)
|
||
self._report_thread = ReportGenerateThread(wd, out, title, opts)
|
||
self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection)
|
||
self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
|
||
self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection)
|
||
self._report_thread.finished.connect(
|
||
lambda: self.generate_btn.setEnabled(True), Qt.QueuedConnection
|
||
)
|
||
self._report_thread.start()
|
||
self._forward_log("已开始生成 Word 报告…", "info")
|
||
|
||
def _forward_log(self, msg: str, level: str):
|
||
mw = self.main_window
|
||
if mw is not None and hasattr(mw, "log_message"):
|
||
mw.log_message(msg, level)
|
||
else:
|
||
print(f"[{level}] {msg}")
|
||
|
||
def _on_report_ok(self, path: str):
|
||
QMessageBox.information(self, "完成", f"报告已生成:\n{path}")
|
||
self._forward_log(f"Word 报告已保存: {path}", "info")
|
||
|
||
def _on_report_fail(self, err: str):
|
||
QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
|
||
self._forward_log(err, "error")
|